mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3f64e3a0 | ||
|
|
b2c2799939 | ||
|
|
fb264bcbcd | ||
|
|
a2757e7856 | ||
|
|
6d84924c18 |
@@ -142,6 +142,9 @@ jobs:
|
||||
- name: Type check
|
||||
run: pnpm exec tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Check for build artifacts
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a2757e7: Fix pre-commit hook hang issue in config command by using dynamic import for @inquirer/prompts
|
||||
|
||||
The config command was causing pre-commit hooks to hang indefinitely due to stdin event listeners being registered at module load time. This fix converts the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively.
|
||||
|
||||
Also adds ESLint with a rule to prevent static @inquirer imports, avoiding future regressions.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
extends: [...tseslint.configs.recommended],
|
||||
rules: {
|
||||
// Prevent static imports of @inquirer modules to avoid pre-commit hook hangs.
|
||||
// These modules have side effects that can keep the Node.js event loop alive
|
||||
// when stdin is piped. Use dynamic import() instead.
|
||||
// See: https://github.com/Fission-AI/OpenSpec/issues/367
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@inquirer/*'],
|
||||
message:
|
||||
'Use dynamic import() for @inquirer modules to prevent pre-commit hook hangs. See #367.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// Disable rules that need broader cleanup - focus on critical issues only
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-empty': 'off',
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// init.ts is dynamically imported from cli/index.ts, so static @inquirer
|
||||
// imports there are safe - they won't be loaded at CLI startup
|
||||
files: ['src/core/init.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', '*.js', '*.mjs'],
|
||||
}
|
||||
);
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.1",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -38,6 +38,7 @@
|
||||
"!dist/**/*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"build": "node build.js",
|
||||
"dev": "tsc --watch",
|
||||
"dev:cli": "pnpm build && node bin/openspec.js",
|
||||
@@ -62,7 +63,9 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
+762
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { Command } from 'commander';
|
||||
import { confirm } from '@inquirer/prompts';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
@@ -153,6 +152,7 @@ export function registerConfigCommand(program: Command): void {
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: 'Reset all configuration to defaults?',
|
||||
default: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
||||
import { nearestMatches } from '../utils/match.js';
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ExecuteOptions {
|
||||
strict?: boolean;
|
||||
json?: boolean;
|
||||
noInteractive?: boolean;
|
||||
interactive?: boolean; // Commander sets this to false when --no-interactive is used
|
||||
concurrency?: string;
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@ export class ValidateCommand {
|
||||
await this.runBulkValidation({
|
||||
changes: !!options.all || !!options.changes,
|
||||
specs: !!options.all || !!options.specs,
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,8 +181,8 @@ export class ValidateCommand {
|
||||
bullets.forEach(b => console.error(` ${b}`));
|
||||
}
|
||||
|
||||
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const spinner = !opts.json ? ora('Validating...').start() : undefined;
|
||||
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
|
||||
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
|
||||
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type InteractiveOptions = {
|
||||
export type InteractiveOptions = {
|
||||
/**
|
||||
* Explicit "disable prompts" flag passed by internal callers.
|
||||
*/
|
||||
@@ -9,7 +9,12 @@ type InteractiveOptions = {
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
/**
|
||||
* Resolves whether non-interactive mode is requested.
|
||||
* Handles both explicit `noInteractive: true` and Commander.js style `interactive: false`.
|
||||
* Use this helper instead of manually checking options.noInteractive to avoid bugs.
|
||||
*/
|
||||
export function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value?.noInteractive === true || value?.interactive === false;
|
||||
}
|
||||
@@ -17,6 +22,8 @@ function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (resolveNoInteractive(value)) return false;
|
||||
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
|
||||
// Respect the standard CI environment variable (set by GitHub Actions, GitLab CI, Travis, etc.)
|
||||
if ('CI' in process.env) return false;
|
||||
return !!process.stdin.isTTY;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,4 +130,18 @@ describe('top-level validate command', () => {
|
||||
const result = await runCLI(['validate', changeId], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('respects --no-interactive flag passed via CLI', async () => {
|
||||
// This test ensures Commander.js --no-interactive flag is correctly parsed
|
||||
// and passed to the validate command. The flag sets options.interactive = false
|
||||
// (not options.noInteractive = true) due to Commander.js convention.
|
||||
const result = await runCLI(['validate', '--specs', '--no-interactive'], {
|
||||
cwd: testDir,
|
||||
// Don't set OPEN_SPEC_INTERACTIVE to ensure we're testing the flag itself
|
||||
env: { ...process.env, OPEN_SPEC_INTERACTIVE: undefined },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Should complete without hanging and without prompts
|
||||
expect(result.stderr).not.toContain('What would you like to validate?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';
|
||||
|
||||
describe('interactive utilities', () => {
|
||||
let originalOpenSpecInteractive: string | undefined;
|
||||
let originalCI: string | undefined;
|
||||
let originalStdinIsTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
|
||||
originalCI = process.env.CI;
|
||||
originalStdinIsTTY = process.stdin.isTTY;
|
||||
|
||||
// Clear environment for clean testing
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
delete process.env.CI;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
if (originalOpenSpecInteractive !== undefined) {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
|
||||
} else {
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
}
|
||||
if (originalCI !== undefined) {
|
||||
process.env.CI = originalCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
// Restore stdin.isTTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNoInteractive', () => {
|
||||
it('should return true when noInteractive is true', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when interactive is false (Commander.js style)', () => {
|
||||
// This is how Commander.js handles --no-interactive flag
|
||||
expect(resolveNoInteractive({ interactive: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when noInteractive is false', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is true', () => {
|
||||
expect(resolveNoInteractive({ interactive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty options object', () => {
|
||||
expect(resolveNoInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(resolveNoInteractive(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle boolean value true', () => {
|
||||
expect(resolveNoInteractive(true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle boolean value false', () => {
|
||||
expect(resolveNoInteractive(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize noInteractive over interactive when both set', () => {
|
||||
// noInteractive: true should win
|
||||
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
|
||||
// If noInteractive is false, check interactive
|
||||
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInteractive', () => {
|
||||
it('should return false when noInteractive is true', () => {
|
||||
expect(isInteractive({ noInteractive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
|
||||
expect(isInteractive({ interactive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = '0';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set', () => {
|
||||
process.env.CI = 'true';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set to any value', () => {
|
||||
// CI can be set to any value, not just "true"
|
||||
process.env.CI = '1';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when stdin is not a TTY', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and no flags disable it', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and options are undefined', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user