Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
111 changes: 111 additions & 0 deletions packages/varlock/src/cli/commands/audit.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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);
}

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 scanResult = await scanCodeForEnvVars({ cwd: finalScanRoot });
const schemaKeys = Object.keys(envGraph.configSchema);

const diff = diffSchemaAndCodeKeys(schemaKeys, scanResult.keys);

if (diff.missingInSchema.length === 0 && diff.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 (diff.unusedInSchema.length > 0) {
console.error(ansis.yellow(`Unused in schema (${diff.unusedInSchema.length}):`));
for (const key of diff.unusedInSchema) {
console.error(` - ${ansis.bold(key)}`);
}
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
151 changes: 151 additions & 0 deletions packages/varlock/src/cli/commands/test/audit.command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import path from 'node:path';
import {
afterEach, beforeEach, describe, expect, test, vi,
} from 'vitest';

import { diffSchemaAndCodeKeys } from '../../helpers/audit-diff';
import { commandFn } from '../audit.command';

const {
gracefulExitMock,
loadVarlockEnvGraphMock,
scanCodeForEnvVarsMock,
fsStatMock,
} = vi.hoisted(() => ({
gracefulExitMock: vi.fn(),
loadVarlockEnvGraphMock: vi.fn(),
scanCodeForEnvVarsMock: vi.fn(),
fsStatMock: vi.fn(),
}));
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

vi.mock('exit-hook', () => ({ gracefulExit: gracefulExitMock }));
vi.mock('../../../lib/load-graph', () => ({ loadVarlockEnvGraph: loadVarlockEnvGraphMock }));
vi.mock('../../helpers/env-var-scanner', () => ({ scanCodeForEnvVars: scanCodeForEnvVarsMock }));
vi.mock('node:fs/promises', () => ({ default: { stat: fsStatMock } }));
vi.mock('../../helpers/error-checks', () => ({
checkForNoEnvFiles: vi.fn(),
checkForSchemaErrors: vi.fn(),
}));

describe('diffSchemaAndCodeKeys', () => {
test('finds missing and unused keys', () => {
const diff = diffSchemaAndCodeKeys(
['A', 'B', 'C'],
['B', 'C', 'D', 'E'],
);

expect(diff.missingInSchema).toEqual(['D', 'E']);
expect(diff.unusedInSchema).toEqual(['A']);
});

test('returns empty diff when in sync', () => {
const diff = diffSchemaAndCodeKeys(
['API_KEY', 'DB_URL'],
['DB_URL', 'API_KEY'],
);

expect(diff.missingInSchema).toEqual([]);
expect(diff.unusedInSchema).toEqual([]);
});
});

describe('audit command', () => {
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);

gracefulExitMock.mockReset();
loadVarlockEnvGraphMock.mockReset();
scanCodeForEnvVarsMock.mockReset();
fsStatMock.mockReset();
fsStatMock.mockRejectedValue(new Error('missing'));

loadVarlockEnvGraphMock.mockResolvedValue({
configSchema: { API_KEY: {}, DATABASE_URL: {} },
rootDataSource: undefined,
basePath: '/repo',
});
});

afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});

test('exits with code 1 when schema drift exists', async () => {
scanCodeForEnvVarsMock.mockResolvedValue({
keys: ['API_KEY', 'MISSING_FROM_SCHEMA'],
references: [
{
key: 'MISSING_FROM_SCHEMA',
filePath: '/repo/src/index.ts',
lineNumber: 10,
columnNumber: 3,
syntax: 'process.env.member',
},
],
scannedFilesCount: 1,
});

await commandFn({ values: {} } as any);

expect(gracefulExitMock).toHaveBeenCalledWith(1);
});

test('exits with code 0 when schema and code match', async () => {
scanCodeForEnvVarsMock.mockResolvedValue({
keys: ['API_KEY', 'DATABASE_URL'],
references: [],
scannedFilesCount: 4,
});

await commandFn({ values: {} } as any);

expect(gracefulExitMock).toHaveBeenCalledWith(0);
});

test('scans from schema path directory when --path is provided', async () => {
scanCodeForEnvVarsMock.mockResolvedValue({
keys: ['API_KEY', 'DATABASE_URL'],
references: [],
scannedFilesCount: 2,
});

await commandFn({ values: { path: './backend/.env.schema' } } as any);

expect(scanCodeForEnvVarsMock).toHaveBeenCalledWith({
cwd: path.resolve('./backend'),
});
});

test('scans from directory path when --path points to dir without trailing slash', async () => {
fsStatMock.mockResolvedValue({ isDirectory: () => true });
scanCodeForEnvVarsMock.mockResolvedValue({
keys: ['API_KEY', 'DATABASE_URL'],
references: [],
scannedFilesCount: 2,
});

await commandFn({ values: { path: './config' } } as any);

expect(scanCodeForEnvVarsMock).toHaveBeenCalledWith({
cwd: path.resolve('./config'),
});
});

test('scans from directory path when --path ends with slash', async () => {
scanCodeForEnvVarsMock.mockResolvedValue({
keys: ['API_KEY', 'DATABASE_URL'],
references: [],
scannedFilesCount: 2,
});

await commandFn({ values: { path: './config/' } } as any);

expect(scanCodeForEnvVarsMock).toHaveBeenCalledWith({
cwd: path.resolve('./config/'),
});
});
});
Loading
Loading