Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fiery-tables-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fix: `--color` now overrides `noColor` from the config file
5 changes: 5 additions & 0 deletions .changeset/fix-compare-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fix: `--strict` was ignored in `--compare` mode, so warnings like duplicate keys never failed the run
15 changes: 7 additions & 8 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,34 +77,33 @@ jobs:
exit 1
fi

# Plain `npm install` on purpose: this checks the tarball installs the way
# it does for a user, so it cannot be locked with `npm ci`. TypeScript is
# Unlocked install on purpose: this checks the tarball installs the way
# it does for a user, so it cannot use a frozen lockfile. TypeScript is
# not installed here: the type check uses the repo's tsc, pinned by
# pnpm-lock.yaml, to keep unpinned downloads to the package's own deps.
- name: Install tarball into empty project
working-directory: ${{ runner.temp }}/smoke
run: |
npm init -y > /dev/null
npm pkg set type=module
npm install --no-audit --no-fund ./dotenv-diff-*.tgz
echo '{ "name": "smoke", "private": true, "type": "module" }' > package.json
pnpm add ./dotenv-diff-*.tgz

- name: Run CLI
working-directory: ${{ runner.temp }}/smoke
run: |
npx --no-install dotenv-diff --help
pnpm exec dotenv-diff --help

printf 'A=1\nB=2\n' > .env
printf 'A=\nB=\nC=\n' > .env.example

# Missing key must fail
if npx --no-install dotenv-diff --compare --ci; then
if pnpm exec dotenv-diff --compare --ci; then
echo "::error::Expected --compare to fail when a key is missing"
exit 1
fi

# Matching files must pass
printf 'C=3\n' >> .env
npx --no-install dotenv-diff --compare --ci
pnpm exec dotenv-diff --compare --ci

- name: Import ESM entry
working-directory: ${{ runner.temp }}/smoke
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration_and_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ See [Baseline Workflow](./baseline.md) for recommendations on when to create, re

### `--color`

Enables colored output in the terminal (enabled by default).
Forces colored output. Colors are on by default in a terminal

Example usage:

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
"@repo/eslint-config": "workspace:*",
"@repo/tsconfig": "workspace:*",
"@repo/vitest": "workspace:*",
"@types/node": "^26.6.5",
"@types/node": "^26.6.6",
"@types/prompts": "^2.4.9",
"@typescript-eslint/eslint-plugin": "^8.72.0",
"@typescript-eslint/parser": "^8.72.0",
"@typescript-eslint/eslint-plugin": "^8.72.1",
"@typescript-eslint/parser": "^8.72.1",
"@vitest/coverage-v8": "5.0.5",
"eslint": "^10.13.0",
"husky": "^9.1.7",
Expand Down
4 changes: 2 additions & 2 deletions packages/@repo/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"format": "prettier --write \"**/*.{ts,js,json,md,yml}\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.72.0",
"@typescript-eslint/parser": "^8.72.0",
"@typescript-eslint/eslint-plugin": "^8.72.1",
"@typescript-eslint/parser": "^8.72.1",
"eslint": "^10.13.0"
}
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
],
"sideEffects": false,
"scripts": {
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"fs.chmodSync('dist/bin/dotenv-diff.js',0o755)\"",
"typecheck": "tsc --noEmit",
"dev": "npx dotenv-diff",
"test": "vitest run",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function createProgram() {

// Output
.option('--json', 'Output results in JSON format')
.option('--color', 'Enable colored output')
.option('--color', 'Force colored output')
.option('--no-color', 'Disable colored output')
.option('--show-stats', 'Show statistics')
.option('--no-show-stats', 'Do not show statistics')
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ function buildCompareOptions(
ignoreRegex: opts.ignoreRegex,
showStats: opts.showStats,
uppercaseKeys: opts.uppercaseKeys,
strict: opts.strict,
suggest: opts.suggest,
collect: (e: CompareJsonEntry) => report.push(e),
...(opts.only ? { only: opts.only } : {}),
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function processPair(
dupsEx,
opts.json ?? false,
opts.fix ?? false,
opts.strict ?? false,
);

const entry = compareJsonOutput({
Expand Down Expand Up @@ -281,12 +282,18 @@ function printPairOutputs(
dupsEnv: ReturnType<typeof findDuplicateKeys>,
opts: Readonly<ComparisonOptions>,
): void {
printIssues(filtered, opts.json ?? false, opts.fix ?? false);
printIssues(
filtered,
opts.json ?? false,
opts.fix ?? false,
opts.strict ?? false,
);

if (filtered.gitignoreIssue && !opts.json && !opts.fix) {
printGitignoreWarning({
envFile: envName,
reason: filtered.gitignoreIssue.reason,
strict: opts.strict ?? false,
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function normalizeOptions(raw: RawOptions): Options {
const allowDuplicates = toBool(raw.allowDuplicates);
const fix = toBool(raw.fix);
const json = toBool(raw.json);
const noColor = toBool(raw.noColor);
const noColor = raw.color !== true && toBool(raw.noColor);
const compare = toBool(raw.compare);
const strict = toBool(raw.strict);
const scanUsage = raw.scanUsage != null ? toBool(raw.scanUsage) : !compare;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface RawOptions {
ignoreRegex?: string | string[];
only?: string | string[];
compare?: boolean;
color?: boolean;
noColor?: boolean;
scanUsage?: boolean;
includeFiles?: string | string[];
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/ui/compare/printIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ import {
* @param filtered The filtered comparison results.
* @param json Whether to output in JSON format.
* @param fix Whether fix mode is enabled (skips printing missing keys as they will be fixed).
* @param strict Whether strict mode is enabled (colours warnings as errors).
* @returns void
*/
export function printIssues(
filtered: Filtered,
json: boolean,
fix: boolean = false,
strict: boolean = false,
): void {
if (json) return;

const indicator = strict ? error('▸') : warning('▸');
const textColor = strict ? error : warning;

if (filtered.missing.length && !fix) {
const suggestionByKey = new Map(
(filtered.suggestions ?? []).map((s) => [s.key, s.didYouMean]),
Expand All @@ -43,31 +48,31 @@ export function printIssues(

if (filtered.extra?.length) {
console.log();
console.log(`${warning('▸')} ${header('Extra keys (not in example)')}`);
console.log(`${indicator} ${header('Extra keys (not in example)')}`);
console.log(`${divider}`);
for (const key of filtered.extra) {
console.log(`${label(padLabel(key))}${warning('extra')}`);
console.log(`${label(padLabel(key))}${textColor('extra')}`);
}
console.log(`${divider}`);
}

if (filtered.empty?.length) {
console.log();
console.log(`${warning('▸')} ${header('Empty values')}`);
console.log(`${indicator} ${header('Empty values')}`);
console.log(`${divider}`);
for (const key of filtered.empty) {
console.log(`${label(padLabel(key))}${warning('empty')}`);
console.log(`${label(padLabel(key))}${textColor('empty')}`);
}
console.log(`${divider}`);
}

if (filtered.mismatches?.length) {
console.log();
console.log(`${warning('▸')} ${header('Value mismatches')}`);
console.log(`${indicator} ${header('Value mismatches')}`);
console.log(`${divider}`);
for (const { key, expected, actual } of filtered.mismatches) {
console.log(
`${label(padLabel(key))}${warning(`expected: ${expected}`)} ${dim(`got: ${actual}`)}`,
`${label(padLabel(key))}${textColor(`expected: ${expected}`)} ${dim(`got: ${actual}`)}`,
);
}
console.log(`${divider}`);
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/e2e/cli.flags.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,42 @@ describe('duplicate detection', () => {
expect(res.stdout).not.toContain('NOT_DETECTED_VAR');
});
});

describe('--color flag', () => {
// Strip colour env vars so only the flag decides; stdout is a pipe, not a TTY
const { FORCE_COLOR: _force, NO_COLOR: _no, ...env } = process.env;
const ANSI = /\x1b\[/;

function setup() {
const cwd = tmpDir();
fs.writeFileSync(path.join(cwd, '.env'), 'A=1\n');
fs.writeFileSync(path.join(cwd, '.env.example'), 'A=\nB=\n');
return cwd;
}

it('prints no colours when piped without --color', () => {
const res = runCli(setup(), ['--compare', '--ci'], env);
expect(res.stdout).not.toMatch(ANSI);
});

it('forces colours when piped with --color', () => {
const res = runCli(setup(), ['--compare', '--ci', '--color'], env);
expect(res.stdout).toMatch(ANSI);
});

it('lets --color override noColor in the config file', () => {
const cwd = setup();
fs.writeFileSync(
path.join(cwd, 'dotenv-diff.config.json'),
JSON.stringify({ noColor: true }),
);

// Without --color the config disables colours on the main output
const off = runCli(cwd, ['--compare', '--ci'], env);
expect(off.stdout).toContain('▸ Missing keys');
expect(off.stdout).not.toMatch(/\x1b\[[0-9;]*m▸\S* \S*Missing keys/);

const on = runCli(cwd, ['--compare', '--ci', '--color'], env);
expect(on.stdout).toMatch(/\x1b\[[0-9;]*m▸\S* \S*Missing keys/);
});
});
18 changes: 14 additions & 4 deletions packages/cli/test/e2e/cli.strict.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,33 @@ describe('--strict mode', () => {
});

describe('--strict mode with --compare', () => {
it('warns on duplicate variables in .env', () => {
it('fails on duplicate variables in .env', () => {
const cwd = tmpDir();
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\nFOO=2\n');
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n');

const res = runCli(cwd, ['--strict', '--compare']);
expect(res.status).toBe(0);
expect(res.status).toBe(1);
expect(res.stdout).toContain('Duplicate keys');
});

it('warns on duplicate variables in .env.example', () => {
it('fails on duplicate variables in .env.example', () => {
const cwd = tmpDir();
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n');
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\nFOO=\n');

const res = runCli(cwd, ['--strict', '--compare']);
expect(res.status).toBe(0);
expect(res.status).toBe(1);
expect(res.stdout).toContain('Duplicate keys in .env.example');
});

it('succeeds when there are no warnings', () => {
const cwd = tmpDir();
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n');
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n');
fs.writeFileSync(path.join(cwd, '.gitignore'), '.env');

const res = runCli(cwd, ['--strict', '--compare']);
expect(res.status).toBe(0);
});
});
34 changes: 34 additions & 0 deletions packages/cli/test/unit/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,40 @@ describe('run', () => {
existsSpy.mockRestore();
});

it('forwards strict, only and suggest to compare options', async () => {
const program = {
parse: vi.fn(),
opts: vi.fn(() => ({ compare: true })),
} as unknown as Command;

mockNormalizeOptions.mockReturnValue(
createBaseOptions({
compare: true,
envFlag: 'a.env',
exampleFlag: 'b.env',
strict: true,
only: ['duplicate'],
suggest: false,
}),
);

const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
mockCompareMany.mockResolvedValue({ exitWithError: false });

await run(program);

expect(mockCompareMany).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
strict: true,
only: ['duplicate'],
suggest: false,
}),
);

existsSpy.mockRestore();
});

it('routes to explainKey when opts.explain is set and exits with 0', async () => {
const program = {
parse: vi.fn(),
Expand Down
Loading
Loading