Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,44 @@ You can also set it up manually -- see the [Secrets guide](/guides/secrets/#scan

</div>

<div>
### `varlock audit` ||audit||

Scans your source code for environment variable references and compares them against keys defined in your schema.

This command reports two drift categories:
- **Missing in schema**: key is used in code but not declared in schema
- **Unused in schema**: key is declared in schema but not referenced in code

Exit codes:
- `0` when schema and code are in sync
- `1` when drift is detected

```bash
varlock audit [options]
```

**Options:**
- `--path` / `-p`: Path to a specific `.env` file or directory to use as the schema entry point

**Examples:**
```bash
# Audit current project
varlock audit

# Audit using a specific .env file as schema entry point
varlock audit --path .env.prod

# Audit using a directory as schema entry point
varlock audit --path ./config
```

:::note
When `--path` points to a directory, code scanning is scoped to that directory tree. When it points to a file, scanning is scoped to that file's parent directory.
:::

</div>

<div>
### `varlock typegen` ||typegen||

Expand Down
2 changes: 2 additions & 0 deletions packages/varlock/src/cli/cli-executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { commandSpec as explainCommandSpec } from './commands/explain.command';
import { commandSpec as scanCommandSpec } from './commands/scan.command';
import { commandSpec as typegenCommandSpec } from './commands/typegen.command';
import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command';
import { commandSpec as auditCommandSpec } from './commands/audit.command';
// import { commandSpec as loginCommandSpec } from './commands/login.command';
// import { commandSpec as pluginCommandSpec } from './commands/plugin.command';

Expand Down Expand Up @@ -58,6 +59,7 @@ subCommands.set('explain', buildLazyCommand(explainCommandSpec, async () => awai
subCommands.set('help', buildLazyCommand(helpCommandSpec, async () => await import('./commands/help.command')));
subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => await import('./commands/telemetry.command')));
subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command')));
subCommands.set('audit', buildLazyCommand(auditCommandSpec, async () => await import('./commands/audit.command')));
subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command')));
subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command')));
// subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command')));
Expand Down
158 changes: 158 additions & 0 deletions packages/varlock/src/cli/commands/audit.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import ansis from 'ansis';
import { define } from 'gunshi';

import { FileBasedDataSource } from '../../env-graph';
import { loadVarlockEnvGraph } from '../../lib/load-graph';
import { checkForNoEnvFiles, checkForSchemaErrors } from '../helpers/error-checks';
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
import {
scanCodeForEnvVars,
type EnvVarReference,
} from '../helpers/env-var-scanner';
import { gracefulExit } from 'exit-hook';
import { diffSchemaAndCodeKeys } from '../helpers/audit-diff';

export const commandSpec = define({
name: 'audit',
description: 'Audit code env var usage against your .env.schema',
args: {
path: {
type: 'string',
short: 'p',
description: 'Path to a specific .env file or directory to use as the schema entry point',
},
},
examples: `
Scans your source code for environment variable references and compares them
to keys defined in your varlock schema.

Examples:
varlock audit # Audit current project
varlock audit --path .env.prod # Audit using a specific env entry point
`.trim(),
});

function formatReference(cwd: string, ref: EnvVarReference): string {
const relPath = path.relative(cwd, ref.filePath);
return `${relPath}:${ref.lineNumber}:${ref.columnNumber}`;
}

async function getScanRootFromEntryPath(providedEntryPath: string): Promise<string> {
const resolved = path.resolve(providedEntryPath);
try {
const entryStat = await fs.stat(resolved);
if (entryStat.isDirectory()) return resolved;
} catch {
// loadVarlockEnvGraph validates path before this point; fallback keeps behavior predictable
}

if (providedEntryPath.endsWith('/') || providedEntryPath.endsWith(path.sep)) {
return resolved;
}
return path.dirname(resolved);
}

function collectStringArgs(input: unknown, out: Array<string>) {
if (Array.isArray(input)) {
for (const entry of input) collectStringArgs(entry, out);
return;
}
if (typeof input !== 'string') return;

const normalized = input.trim().replace(/^\.\//, '').replace(/[/\\]+$/, '');
if (!normalized) return;
out.push(normalized);
}

async function getCustomAuditIgnorePaths(envGraph: any): Promise<Array<string>> {
const rootDecFns = typeof envGraph?.getRootDecFns === 'function'
? envGraph.getRootDecFns('auditIgnorePaths')
: [];

const mergedPaths: Array<string> = [];
for (const dec of rootDecFns || []) {
const resolved = await dec.resolve();
collectStringArgs(resolved?.arr, mergedPaths);
}

return [...new Set(mergedPaths)];
}

export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) => {
const providedEntryPath = ctx.values.path as string | undefined;
const envGraph = await loadVarlockEnvGraph({
entryFilePath: providedEntryPath,
});

checkForSchemaErrors(envGraph);
checkForNoEnvFiles(envGraph);

const schemaScanRoot = (() => {
if (providedEntryPath) {
return undefined;
}

const rootSource = envGraph.rootDataSource;
if (rootSource instanceof FileBasedDataSource) {
return path.dirname(rootSource.fullPath);
}
return envGraph.basePath ?? process.cwd();
})();

const finalScanRoot = providedEntryPath
? await getScanRootFromEntryPath(providedEntryPath)
: (schemaScanRoot ?? process.cwd());

const customIgnoredPaths = await getCustomAuditIgnorePaths(envGraph);
if (customIgnoredPaths.length > 0) {
console.log(`ℹ️ Skipping custom ignored paths: ${customIgnoredPaths.join(', ')}`);
}

const scanResult = await scanCodeForEnvVars(
{ cwd: finalScanRoot },
customIgnoredPaths,
);
const schemaKeys = Object.keys(envGraph.configSchema);

const diff = diffSchemaAndCodeKeys(schemaKeys, scanResult.keys);
const unusedInSchema: Array<string> = [];
for (const key of diff.unusedInSchema) {
const item = envGraph.configSchema[key];
const itemDecorators = (item as any)?.decorators as Record<string, unknown> | undefined;
const isIgnored = (typeof item?.getDec === 'function' && (item.getDec('auditIgnore') as unknown) === true)
|| (itemDecorators?.auditIgnore === true);
if (isIgnored) continue;
unusedInSchema.push(key);
}

if (diff.missingInSchema.length === 0 && unusedInSchema.length === 0) {
console.log(ansis.green(`✅ Schema and code references are in sync. (scanned ${scanResult.scannedFilesCount} file${scanResult.scannedFilesCount === 1 ? '' : 's'})`));
gracefulExit(0);
return;
}

console.error(ansis.red('\n🚨 Schema/code mismatch detected:\n'));

if (diff.missingInSchema.length > 0) {
console.error(ansis.red(`Missing in schema (${diff.missingInSchema.length}):`));
for (const key of diff.missingInSchema) {
const refs = scanResult.references.filter((r) => r.key === key).slice(0, 3);
const refPreview = refs.map((r) => formatReference(finalScanRoot, r)).join(', ');
console.error(` - ${ansis.bold(key)}${refPreview ? ansis.dim(` (seen at ${refPreview})`) : ''}`);
}
console.error('');
}

if (unusedInSchema.length > 0) {
console.error(ansis.yellow(`Unused in schema (${unusedInSchema.length}):`));
for (const key of unusedInSchema) {
console.error(` - ${ansis.bold(key)}`);
}
console.error(ansis.dim('(Hint: If this is used by an external tool, add # @auditIgnore to the item)'));
console.error('');
}

gracefulExit(1);
};
36 changes: 36 additions & 0 deletions packages/varlock/src/cli/commands/init.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import prompts from '../helpers/prompts';
import { fmt, logLines } from '../helpers/pretty-format';
import {
detectRedundantValues, ensureAllItemsExist, inferSchemaUpdates, type DetectedEnvFile,
inferItemDecorators,
} from '../helpers/infer-schema';
import { detectJsPackageManager, installJsDependency } from '../helpers/js-package-manager-utils';
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
import { findEnvFiles } from '../helpers/find-env-files';
import { tryCatch } from '@env-spec/utils/try-catch';
import { scanCodeForEnvVars } from '../helpers/env-var-scanner';

export const commandSpec = define({
name: 'init',
Expand Down Expand Up @@ -104,6 +106,12 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
exampleFileToConvert = selectedExample;
}

let scannedCodeEnvKeys: Array<string> = [];
if (!exampleFileToConvert) {
const scanResult = await scanCodeForEnvVars();
scannedCodeEnvKeys = scanResult.keys;
}

// update the schema
const parsedEnvSchemaFile = exampleFileToConvert?.parsedFile || parseEnvSpecDotEnvFile('');
if (!parsedEnvSchemaFile) throw new Error('expected parsed .env example file');
Expand Down Expand Up @@ -131,6 +139,27 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
// add items we find in other env files, but are missing in the schema/example
ensureAllItemsExist(parsedEnvSchemaFile, Object.values(parsedEnvFiles));

const scannedCodeKeysToAdd = !exampleFileToConvert
? scannedCodeEnvKeys.filter((key) => !parsedEnvSchemaFile.configItems.find((i) => i.key === key))
: [];

// add items we detect in source code if no sample/example file was provided
if (scannedCodeKeysToAdd.length > 0) {
envSpecUpdater.injectFromStr(parsedEnvSchemaFile, [
'',
'# items added to schema by `varlock init`',
'# detected by scanning your source code for env var references',
'# PLEASE REVIEW THESE!',
'# ---',
'',
].join('\n'), { location: 'end' });

for (const key of scannedCodeKeysToAdd) {
envSpecUpdater.injectFromStr(parsedEnvSchemaFile, `${key}=`);
inferItemDecorators(parsedEnvSchemaFile, key, '');
}
}

// write new updated schema file
const schemaFilePath = path.join(process.cwd(), '.env.schema');
await fs.writeFile(schemaFilePath, parsedEnvSchemaFile.toString());
Expand All @@ -142,6 +171,13 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
`Your ${fmt.fileName(exampleFileToConvert.fileName)} has been used to generate your new ${fmt.fileName('.env.schema')}:`,
fmt.filePath(schemaFilePath),
]);
} else if (scannedCodeKeysToAdd.length > 0) {
logLines([
'',
`Your new ${fmt.fileName('.env.schema')} file has been created from scanned source code references:`,
fmt.filePath(schemaFilePath),
ansis.dim(`Detected ${scannedCodeEnvKeys.length} env var key${scannedCodeEnvKeys.length === 1 ? '' : 's'} in your codebase.`),
]);
} else {
logLines([
'',
Expand Down
Loading