Skip to content

Commit 68e0a7e

Browse files
authored
fix(cli): prevent hang in pre-commit hooks by using dynamic imports (#380)
Fixes #367 The CLI was hanging when run as a pre-commit hook because @inquirer/prompts was statically imported at module load time. Even when prompts were never called (e.g., `openspec validate --specs --no-interactive`), the import itself could set up stdin references that prevented clean process exit when stdin was piped. Changes: - Convert all static `@inquirer/prompts` imports to dynamic imports - Dynamically import `InitCommand` (which uses `@inquirer/core`) - Update `isInteractive()` to accept options object with both `noInteractive` and Commander's negated `interactive` property - Handle empty validation queue with proper exit code Now when running in non-interactive mode, the inquirer modules are never loaded, allowing the process to exit cleanly after completion.
1 parent f39cc5c commit 68e0a7e

File tree

8 files changed

+61
-22
lines changed

8 files changed

+61
-22
lines changed

src/cli/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { createRequire } from 'module';
33
import ora from 'ora';
44
import path from 'path';
55
import { promises as fs } from 'fs';
6-
import { InitCommand } from '../core/init.js';
76
import { AI_TOOLS } from '../core/config.js';
87
import { UpdateCommand } from '../core/update.js';
98
import { ListCommand } from '../core/list.js';
@@ -30,7 +29,7 @@ program.option('--no-color', 'Disable color output');
3029
// Apply global flags before any command runs
3130
program.hook('preAction', (thisCommand) => {
3231
const opts = thisCommand.opts();
33-
if (opts.noColor) {
32+
if (opts.color === false) {
3433
process.env.NO_COLOR = '1';
3534
}
3635
});
@@ -63,6 +62,7 @@ program
6362
}
6463
}
6564

65+
const { InitCommand } = await import('../core/init.js');
6666
const initCommand = new InitCommand({
6767
tools: options?.tools,
6868
});

src/commands/change.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
3-
import { select } from '@inquirer/prompts';
43
import { JsonConverter } from '../core/converters/json-converter.js';
54
import { Validator } from '../core/validation/validator.js';
65
import { ChangeParser } from '../core/parsers/change-parser.js';
@@ -30,9 +29,10 @@ export class ChangeCommand {
3029
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
3130

3231
if (!changeName) {
33-
const canPrompt = isInteractive(options?.noInteractive);
32+
const canPrompt = isInteractive(options);
3433
const changes = await this.getActiveChanges(changesPath);
3534
if (canPrompt && changes.length > 0) {
35+
const { select } = await import('@inquirer/prompts');
3636
const selected = await select({
3737
message: 'Select a change to show',
3838
choices: changes.map(id => ({ name: id, value: id })),
@@ -186,9 +186,10 @@ export class ChangeCommand {
186186
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
187187

188188
if (!changeName) {
189-
const canPrompt = isInteractive(options?.noInteractive);
189+
const canPrompt = isInteractive(options);
190190
const changes = await getActiveChangeIds();
191191
if (canPrompt && changes.length > 0) {
192+
const { select } = await import('@inquirer/prompts');
192193
const selected = await select({
193194
message: 'Select a change to validate',
194195
choices: changes.map(id => ({ name: id, value: id })),

src/commands/completion.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import ora from 'ora';
2-
import { confirm } from '@inquirer/prompts';
32
import { CompletionFactory } from '../core/completions/factory.js';
43
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
54
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
@@ -179,6 +178,7 @@ export class CompletionCommand {
179178

180179
// Prompt for confirmation unless --yes flag is provided
181180
if (!skipConfirmation) {
181+
const { confirm } = await import('@inquirer/prompts');
182182
const confirmed = await confirm({
183183
message: 'Remove OpenSpec configuration from ~/.zshrc?',
184184
default: false,

src/commands/show.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { select } from '@inquirer/prompts';
21
import path from 'path';
32
import { isInteractive } from '../utils/interactive.js';
43
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
@@ -13,11 +12,12 @@ const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
1312

1413
export class ShowCommand {
1514
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
16-
const interactive = isInteractive(options.noInteractive);
15+
const interactive = isInteractive(options);
1716
const typeOverride = this.normalizeType(options.type);
1817

1918
if (!itemName) {
2019
if (interactive) {
20+
const { select } = await import('@inquirer/prompts');
2121
const type = await select<ItemType>({
2222
message: 'What would you like to show?',
2323
choices: [
@@ -44,6 +44,7 @@ export class ShowCommand {
4444
}
4545

4646
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
47+
const { select } = await import('@inquirer/prompts');
4748
if (type === 'change') {
4849
const changes = await getActiveChangeIds();
4950
if (changes.length === 0) {
@@ -135,5 +136,3 @@ export class ShowCommand {
135136
return false;
136137
}
137138
}
138-
139-

src/commands/spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { join } from 'path';
44
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
55
import { Validator } from '../core/validation/validator.js';
66
import type { Spec } from '../core/schemas/index.js';
7-
import { select } from '@inquirer/prompts';
87
import { isInteractive } from '../utils/interactive.js';
98
import { getSpecIds } from '../utils/item-discovery.js';
109

@@ -70,9 +69,10 @@ export class SpecCommand {
7069

7170
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
7271
if (!specId) {
73-
const canPrompt = isInteractive(options?.noInteractive);
72+
const canPrompt = isInteractive(options);
7473
const specIds = await getSpecIds();
7574
if (canPrompt && specIds.length > 0) {
75+
const { select } = await import('@inquirer/prompts');
7676
specId = await select({
7777
message: 'Select a spec to show',
7878
choices: specIds.map(id => ({ name: id, value: id })),
@@ -204,9 +204,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
204204
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
205205
try {
206206
if (!specId) {
207-
const canPrompt = isInteractive(options?.noInteractive);
207+
const canPrompt = isInteractive(options);
208208
const specIds = await getSpecIds();
209209
if (canPrompt && specIds.length > 0) {
210+
const { select } = await import('@inquirer/prompts');
210211
specId = await select({
211212
message: 'Select a spec to validate',
212213
choices: specIds.map(id => ({ name: id, value: id })),
@@ -247,4 +248,4 @@ export function registerSpecCommand(rootProgram: typeof program) {
247248
});
248249

249250
return specCommand;
250-
}
251+
}

src/commands/validate.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { select } from '@inquirer/prompts';
21
import ora from 'ora';
32
import path from 'path';
43
import { Validator } from '../core/validation/validator.js';
@@ -29,7 +28,7 @@ interface BulkItemResult {
2928

3029
export class ValidateCommand {
3130
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
32-
const interactive = isInteractive(options.noInteractive);
31+
const interactive = isInteractive(options);
3332

3433
// Handle bulk flags first
3534
if (options.all || options.changes || options.specs) {
@@ -64,6 +63,7 @@ export class ValidateCommand {
6463
}
6564

6665
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
66+
const { select } = await import('@inquirer/prompts');
6767
const choice = await select({
6868
message: 'What would you like to validate?',
6969
choices: [
@@ -212,6 +212,28 @@ export class ValidateCommand {
212212
});
213213
}
214214

215+
if (queue.length === 0) {
216+
spinner?.stop();
217+
218+
const summary = {
219+
totals: { items: 0, passed: 0, failed: 0 },
220+
byType: {
221+
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
222+
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
223+
},
224+
} as const;
225+
226+
if (opts.json) {
227+
const out = { items: [] as BulkItemResult[], summary, version: '1.0' };
228+
console.log(JSON.stringify(out, null, 2));
229+
} else {
230+
console.log('No items found to validate.');
231+
}
232+
233+
process.exitCode = 0;
234+
return;
235+
}
236+
215237
const results: BulkItemResult[] = [];
216238
let index = 0;
217239
let running = 0;
@@ -301,5 +323,3 @@ function getPlannedType(index: number, changeIds: string[], specIds: string[]):
301323
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
302324
return undefined;
303325
}
304-
305-

src/core/archive.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
3-
import { select, confirm } from '@inquirer/prompts';
43
import { FileSystemUtils } from '../utils/file-system.js';
54
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
65
import { Validator } from './validation/validator.js';
@@ -125,6 +124,7 @@ export class ArchiveCommand {
125124
const timestamp = new Date().toISOString();
126125

127126
if (!options.yes) {
127+
const { confirm } = await import('@inquirer/prompts');
128128
const proceed = await confirm({
129129
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
130130
default: false
@@ -149,6 +149,7 @@ export class ArchiveCommand {
149149
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
150150
if (incompleteTasks > 0) {
151151
if (!options.yes) {
152+
const { confirm } = await import('@inquirer/prompts');
152153
const proceed = await confirm({
153154
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
154155
default: false
@@ -179,6 +180,7 @@ export class ArchiveCommand {
179180

180181
let shouldUpdateSpecs = true;
181182
if (!options.yes) {
183+
const { confirm } = await import('@inquirer/prompts');
182184
shouldUpdateSpecs = await confirm({
183185
message: 'Proceed with spec updates?',
184186
default: true
@@ -256,6 +258,7 @@ export class ArchiveCommand {
256258
}
257259

258260
private async selectChange(changesDir: string): Promise<string | null> {
261+
const { select } = await import('@inquirer/prompts');
259262
// Get all directories in changes (excluding archive)
260263
const entries = await fs.readdir(changesDir, { withFileTypes: true });
261264
const changeDirs = entries

src/utils/interactive.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1-
export function isInteractive(noInteractiveFlag?: boolean): boolean {
2-
if (noInteractiveFlag) return false;
1+
type InteractiveOptions = {
2+
/**
3+
* Explicit "disable prompts" flag passed by internal callers.
4+
*/
5+
noInteractive?: boolean;
6+
/**
7+
* Commander-style negated option: `--no-interactive` sets this to false.
8+
*/
9+
interactive?: boolean;
10+
};
11+
12+
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
13+
if (typeof value === 'boolean') return value;
14+
return value?.noInteractive === true || value?.interactive === false;
15+
}
16+
17+
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
18+
if (resolveNoInteractive(value)) return false;
319
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
420
return !!process.stdin.isTTY;
521
}
622

7-

0 commit comments

Comments
 (0)