Skip to content

Commit 9ac6330

Browse files
authored
fix(cli): respect --no-interactive flag in validate command (#395)
* fix(cli): respect --no-interactive flag in validate command The validate command's spinner was starting regardless of the --no-interactive flag, causing hangs in pre-commit hooks. Changes: - Pass noInteractive option to runBulkValidation - Handle Commander.js --no-* flag syntax (sets interactive=false) - Only start ora spinner when in interactive mode - Add CI environment variable check to isInteractive() for industry standard compliance * test: add unit tests for interactive utilities and CLI flag - Export resolveNoInteractive() helper for reuse - Add InteractiveOptions type export for testing - Refactor validate.ts to use resolveNoInteractive() - Add 17 unit tests for isInteractive() and resolveNoInteractive() - Add CLI integration test for --no-interactive flag This prevents future regressions where Commander.js --no-* flag parsing is not properly handled.
1 parent fb264bc commit 9ac6330

File tree

4 files changed

+153
-6
lines changed

4 files changed

+153
-6
lines changed

src/commands/validate.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ora from 'ora';
22
import path from 'path';
33
import { Validator } from '../core/validation/validator.js';
4-
import { isInteractive } from '../utils/interactive.js';
4+
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
55
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
66
import { nearestMatches } from '../utils/match.js';
77

@@ -15,6 +15,7 @@ interface ExecuteOptions {
1515
strict?: boolean;
1616
json?: boolean;
1717
noInteractive?: boolean;
18+
interactive?: boolean; // Commander sets this to false when --no-interactive is used
1819
concurrency?: string;
1920
}
2021

@@ -35,7 +36,7 @@ export class ValidateCommand {
3536
await this.runBulkValidation({
3637
changes: !!options.all || !!options.changes,
3738
specs: !!options.all || !!options.specs,
38-
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
39+
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
3940
return;
4041
}
4142

@@ -180,8 +181,8 @@ export class ValidateCommand {
180181
bullets.forEach(b => console.error(` ${b}`));
181182
}
182183

183-
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
184-
const spinner = !opts.json ? ora('Validating...').start() : undefined;
184+
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
185+
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
185186
const [changeIds, specIds] = await Promise.all([
186187
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
187188
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),

src/utils/interactive.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type InteractiveOptions = {
1+
export type InteractiveOptions = {
22
/**
33
* Explicit "disable prompts" flag passed by internal callers.
44
*/
@@ -9,14 +9,21 @@ type InteractiveOptions = {
99
interactive?: boolean;
1010
};
1111

12-
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
12+
/**
13+
* Resolves whether non-interactive mode is requested.
14+
* Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
15+
* Use this helper instead of manually checking options.noInteractive to avoid bugs.
16+
*/
17+
export function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
1318
if (typeof value === 'boolean') return value;
1419
return value?.noInteractive === true || value?.interactive === false;
1520
}
1621

1722
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
1823
if (resolveNoInteractive(value)) return false;
1924
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
25+
// Respect the standard CI environment variable (set by GitHub Actions, GitLab CI, Travis, etc.)
26+
if ('CI' in process.env) return false;
2027
return !!process.stdin.isTTY;
2128
}
2229

test/commands/validate.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,18 @@ describe('top-level validate command', () => {
130130
const result = await runCLI(['validate', changeId], { cwd: testDir });
131131
expect(result.exitCode).toBe(0);
132132
});
133+
134+
it('respects --no-interactive flag passed via CLI', async () => {
135+
// This test ensures Commander.js --no-interactive flag is correctly parsed
136+
// and passed to the validate command. The flag sets options.interactive = false
137+
// (not options.noInteractive = true) due to Commander.js convention.
138+
const result = await runCLI(['validate', '--specs', '--no-interactive'], {
139+
cwd: testDir,
140+
// Don't set OPEN_SPEC_INTERACTIVE to ensure we're testing the flag itself
141+
env: { ...process.env, OPEN_SPEC_INTERACTIVE: undefined },
142+
});
143+
expect(result.exitCode).toBe(0);
144+
// Should complete without hanging and without prompts
145+
expect(result.stderr).not.toContain('What would you like to validate?');
146+
});
133147
});

test/utils/interactive.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';
3+
4+
describe('interactive utilities', () => {
5+
let originalOpenSpecInteractive: string | undefined;
6+
let originalCI: string | undefined;
7+
let originalStdinIsTTY: boolean | undefined;
8+
9+
beforeEach(() => {
10+
// Save original environment
11+
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
12+
originalCI = process.env.CI;
13+
originalStdinIsTTY = process.stdin.isTTY;
14+
15+
// Clear environment for clean testing
16+
delete process.env.OPEN_SPEC_INTERACTIVE;
17+
delete process.env.CI;
18+
});
19+
20+
afterEach(() => {
21+
// Restore original environment
22+
if (originalOpenSpecInteractive !== undefined) {
23+
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
24+
} else {
25+
delete process.env.OPEN_SPEC_INTERACTIVE;
26+
}
27+
if (originalCI !== undefined) {
28+
process.env.CI = originalCI;
29+
} else {
30+
delete process.env.CI;
31+
}
32+
// Restore stdin.isTTY
33+
Object.defineProperty(process.stdin, 'isTTY', {
34+
value: originalStdinIsTTY,
35+
writable: true,
36+
configurable: true,
37+
});
38+
});
39+
40+
describe('resolveNoInteractive', () => {
41+
it('should return true when noInteractive is true', () => {
42+
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
43+
});
44+
45+
it('should return true when interactive is false (Commander.js style)', () => {
46+
// This is how Commander.js handles --no-interactive flag
47+
expect(resolveNoInteractive({ interactive: false })).toBe(true);
48+
});
49+
50+
it('should return false when noInteractive is false', () => {
51+
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
52+
});
53+
54+
it('should return false when interactive is true', () => {
55+
expect(resolveNoInteractive({ interactive: true })).toBe(false);
56+
});
57+
58+
it('should return false for empty options object', () => {
59+
expect(resolveNoInteractive({})).toBe(false);
60+
});
61+
62+
it('should return false for undefined', () => {
63+
expect(resolveNoInteractive(undefined)).toBe(false);
64+
});
65+
66+
it('should handle boolean value true', () => {
67+
expect(resolveNoInteractive(true)).toBe(true);
68+
});
69+
70+
it('should handle boolean value false', () => {
71+
expect(resolveNoInteractive(false)).toBe(false);
72+
});
73+
74+
it('should prioritize noInteractive over interactive when both set', () => {
75+
// noInteractive: true should win
76+
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
77+
// If noInteractive is false, check interactive
78+
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
79+
});
80+
});
81+
82+
describe('isInteractive', () => {
83+
it('should return false when noInteractive is true', () => {
84+
expect(isInteractive({ noInteractive: true })).toBe(false);
85+
});
86+
87+
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
88+
expect(isInteractive({ interactive: false })).toBe(false);
89+
});
90+
91+
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
92+
process.env.OPEN_SPEC_INTERACTIVE = '0';
93+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
94+
expect(isInteractive({})).toBe(false);
95+
});
96+
97+
it('should return false when CI env var is set', () => {
98+
process.env.CI = 'true';
99+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
100+
expect(isInteractive({})).toBe(false);
101+
});
102+
103+
it('should return false when CI env var is set to any value', () => {
104+
// CI can be set to any value, not just "true"
105+
process.env.CI = '1';
106+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
107+
expect(isInteractive({})).toBe(false);
108+
});
109+
110+
it('should return false when stdin is not a TTY', () => {
111+
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
112+
expect(isInteractive({})).toBe(false);
113+
});
114+
115+
it('should return true when stdin is TTY and no flags disable it', () => {
116+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
117+
expect(isInteractive({})).toBe(true);
118+
});
119+
120+
it('should return true when stdin is TTY and options are undefined', () => {
121+
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
122+
expect(isInteractive(undefined)).toBe(true);
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)