From 971f8ca4a36db8ba7dd591eef09434584b6d3dc4 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:29:25 +1100 Subject: [PATCH 01/12] feat(cli): add openspec config command for global configuration management (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add openspec config command for global configuration management Implements the `openspec config` command with subcommands: - `path`: Show config file location - `list [--json]`: Show all current settings - `get `: Get a specific value (raw output for scripting) - `set [--string]`: Set a value with auto type coercion - `unset `: Remove a key (revert to default) - `reset --all [-y]`: Reset configuration to defaults - `edit`: Open config in $EDITOR/$VISUAL Key features: - Dot notation for nested key access (e.g., featureFlags.someFlag) - Auto type coercion (true/false → boolean, numbers → number) - --string flag to force string storage - Zod schema validation with unknown field passthrough - Reserved --scope flag for future project-local config - Windows-compatible editor spawning with proper path quoting - Shell completion registry integration * test(config): add additional unit tests for validation and coercion - Add tests for unknown fields with various types - Add test to verify error message path for featureFlags - Add test for number values rejection in featureFlags - Add config set simulation tests to verify full coerce → set → validate flow * fix(config): avoid shell parsing in config edit to handle paths with spaces Use spawn with shell: false and pass configPath as an argument instead of building a shell command string. This correctly handles spaces in both the EDITOR path and config file path on all platforms. * chore(openspec): archive add-config-command and create cli-config spec Move completed change to archive and apply spec deltas to create the cli-config specification documenting the config command interface. * Validate config keys on set --- .../changes/add-config-command/proposal.md | 39 -- .../2025-12-21-add-config-command/design.md | 89 +++++ .../2025-12-21-add-config-command/proposal.md | 60 ++++ .../specs/cli-config/spec.md | 213 +++++++++++ .../2025-12-21-add-config-command/tasks.md | 28 ++ openspec/specs/cli-config/spec.md | 217 +++++++++++ src/cli/index.ts | 2 + src/commands/config.ts | 233 ++++++++++++ src/core/completions/command-registry.ts | 73 ++++ src/core/config-schema.ts | 230 ++++++++++++ test/commands/config.test.ts | 175 +++++++++ test/core/config-schema.test.ts | 340 ++++++++++++++++++ 12 files changed, 1660 insertions(+), 39 deletions(-) delete mode 100644 openspec/changes/add-config-command/proposal.md create mode 100644 openspec/changes/archive/2025-12-21-add-config-command/design.md create mode 100644 openspec/changes/archive/2025-12-21-add-config-command/proposal.md create mode 100644 openspec/changes/archive/2025-12-21-add-config-command/specs/cli-config/spec.md create mode 100644 openspec/changes/archive/2025-12-21-add-config-command/tasks.md create mode 100644 openspec/specs/cli-config/spec.md create mode 100644 src/commands/config.ts create mode 100644 src/core/config-schema.ts create mode 100644 test/commands/config.test.ts create mode 100644 test/core/config-schema.test.ts diff --git a/openspec/changes/add-config-command/proposal.md b/openspec/changes/add-config-command/proposal.md deleted file mode 100644 index 8374ef4a8..000000000 --- a/openspec/changes/add-config-command/proposal.md +++ /dev/null @@ -1,39 +0,0 @@ -## Why - -Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `add-global-config-dir` change provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use. - -## What Changes - -Add `openspec config` subcommand with the following operations: - -```bash -openspec config path # Show config file location -openspec config list # Show all current settings -openspec config get # Get a specific value -openspec config set # Set a value -openspec config reset [key] # Reset to defaults (all or specific key) -``` - -**Example usage:** -```bash -$ openspec config path -/Users/me/.config/openspec/config.json - -$ openspec config list -enableTelemetry: true -featureFlags: {} - -$ openspec config set enableTelemetry false -Set enableTelemetry = false - -$ openspec config get enableTelemetry -false -``` - -## Impact - -- Affected specs: New `cli-config` capability -- Affected code: - - New `src/commands/config.ts` - - Update CLI entry point to register config command -- Dependencies: Requires `add-global-config-dir` to be implemented first diff --git a/openspec/changes/archive/2025-12-21-add-config-command/design.md b/openspec/changes/archive/2025-12-21-add-config-command/design.md new file mode 100644 index 000000000..8d0703f7f --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-config-command/design.md @@ -0,0 +1,89 @@ +## Context + +The `global-config` spec defines how OpenSpec reads/writes `config.json`, but users currently must edit it by hand. This command provides a CLI interface to that config. + +## Goals / Non-Goals + +**Goals:** +- Provide a discoverable CLI for config management +- Support scripting with machine-readable output +- Validate config changes with zod schema +- Handle nested keys gracefully + +**Non-Goals:** +- Project-local config (reserved for future via `--scope` flag) +- Complex queries (JSONPath, filtering) +- Config file format migration + +## Decisions + +### Key Naming: camelCase with Dot Notation + +**Decision:** Keys use camelCase matching the JSON structure, with dot notation for nesting. + +**Rationale:** +- Matches the actual JSON keys (no translation layer) +- Dot notation is intuitive and widely used (lodash, jq, kubectl) +- Avoids complexity of supporting multiple casing styles + +**Examples:** +```bash +openspec config get featureFlags # Returns object +openspec config get featureFlags.experimental # Returns nested value +openspec config set featureFlags.newFlag true +``` + +### Type Coercion: Auto-detect with `--string` Override + +**Decision:** Parse values automatically; provide `--string` flag to force string storage. + +**Rationale:** +- Most intuitive for common cases (`true`, `false`, `123`) +- Explicit override for edge cases (storing literal string "true") +- Follows npm/yarn config patterns + +**Coercion rules:** +| Input | Stored As | +|-------|-----------| +| `true`, `false` | boolean | +| Numeric string (`123`, `3.14`) | number | +| Everything else | string | +| Any value with `--string` | string | + +### Output Format: Raw by Default + +**Decision:** `get` prints raw value only. `list` prints YAML-like format by default, JSON with `--json`. + +**Rationale:** +- Raw output enables piping: `VAR=$(openspec config get key)` +- YAML-like is human-readable for inspection +- JSON for automation/scripting + +### Schema Validation: Zod with Unknown Field Passthrough + +**Decision:** Use zod for validation but preserve unknown fields per `global-config` spec. + +**Rationale:** +- Type safety for known fields +- Forward compatibility (old CLI doesn't break new config) +- Follows existing `global-config` spec requirement + +### Reserved Flag: `--scope` + +**Decision:** Reserve `--scope global|project` but only implement `global` initially. + +**Rationale:** +- Avoids breaking change if project-local config is added later +- Clear error message if someone tries `--scope project` + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| Dot notation conflicts with keys containing dots | Rare in practice; document limitation | +| Type coercion surprises | `--string` escape hatch; document rules | +| $EDITOR not set | Check and provide helpful error message | + +## Open Questions + +None - design is straightforward. diff --git a/openspec/changes/archive/2025-12-21-add-config-command/proposal.md b/openspec/changes/archive/2025-12-21-add-config-command/proposal.md new file mode 100644 index 000000000..5a8056f34 --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-config-command/proposal.md @@ -0,0 +1,60 @@ +## Why + +Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `global-config` spec provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use. + +## What Changes + +Add `openspec config` subcommand with the following operations: + +```bash +openspec config path # Show config file location +openspec config list [--json] # Show all current settings +openspec config get # Get a specific value (raw, scriptable) +openspec config set [--string] # Set a value (auto-coerce types) +openspec config unset # Remove a key (revert to default) +openspec config reset --all [-y] # Reset everything to defaults +openspec config edit # Open config in $EDITOR +``` + +**Key design decisions:** +- **Key naming**: Use camelCase to match JSON structure (e.g., `featureFlags.someFlag`) +- **Nested keys**: Support dot notation for nested access +- **Type coercion**: Auto-detect types by default; `--string` flag forces string storage +- **Scriptable output**: `get` prints raw value only (no labels) for easy piping +- **Zod validation**: Use zod for config schema validation and type safety +- **Future-proofing**: Reserve `--scope global|project` flag for potential project-local config + +**Example usage:** +```bash +$ openspec config path +/Users/me/.config/openspec/config.json + +$ openspec config list +featureFlags: {} + +$ openspec config set featureFlags.enableTelemetry false +Set featureFlags.enableTelemetry = false + +$ openspec config get featureFlags.enableTelemetry +false + +$ openspec config list --json +{ + "featureFlags": {} +} + +$ openspec config unset featureFlags.enableTelemetry +Unset featureFlags.enableTelemetry (reverted to default) + +$ openspec config edit +# Opens $EDITOR with config.json +``` + +## Impact + +- Affected specs: New `cli-config` capability +- Affected code: + - New `src/commands/config.ts` + - New `src/core/config-schema.ts` (zod schema) + - Update CLI entry point to register config command +- Dependencies: Requires `global-config` spec (already implemented) diff --git a/openspec/changes/archive/2025-12-21-add-config-command/specs/cli-config/spec.md b/openspec/changes/archive/2025-12-21-add-config-command/specs/cli-config/spec.md new file mode 100644 index 000000000..a856ecf5c --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-config-command/specs/cli-config/spec.md @@ -0,0 +1,213 @@ +# cli-config Specification + +## Purpose + +Provide a CLI interface for viewing and modifying global OpenSpec configuration. Enables users to manage settings without manually editing JSON files, with support for scripting and automation. + +## ADDED Requirements + +### Requirement: Command Structure + +The config command SHALL provide subcommands for all configuration operations. + +#### Scenario: Available subcommands + +- **WHEN** user executes `openspec config --help` +- **THEN** display available subcommands: + - `path` - Show config file location + - `list` - Show all current settings + - `get ` - Get a specific value + - `set ` - Set a value + - `unset ` - Remove a key (revert to default) + - `reset` - Reset configuration to defaults + - `edit` - Open config in editor + +### Requirement: Config Path + +The config command SHALL display the config file location. + +#### Scenario: Show config path + +- **WHEN** user executes `openspec config path` +- **THEN** print the absolute path to the config file +- **AND** exit with code 0 + +### Requirement: Config List + +The config command SHALL display all current configuration values. + +#### Scenario: List config in human-readable format + +- **WHEN** user executes `openspec config list` +- **THEN** display all config values in YAML-like format +- **AND** show nested objects with indentation + +#### Scenario: List config as JSON + +- **WHEN** user executes `openspec config list --json` +- **THEN** output the complete config as valid JSON +- **AND** output only JSON (no additional text) + +### Requirement: Config Get + +The config command SHALL retrieve specific configuration values. + +#### Scenario: Get top-level key + +- **WHEN** user executes `openspec config get ` with a valid top-level key +- **THEN** print the raw value only (no labels or formatting) +- **AND** exit with code 0 + +#### Scenario: Get nested key with dot notation + +- **WHEN** user executes `openspec config get featureFlags.someFlag` +- **THEN** traverse the nested structure using dot notation +- **AND** print the value at that path + +#### Scenario: Get non-existent key + +- **WHEN** user executes `openspec config get ` with a key that does not exist +- **THEN** print nothing (empty output) +- **AND** exit with code 1 + +#### Scenario: Get object value + +- **WHEN** user executes `openspec config get ` where the value is an object +- **THEN** print the object as JSON + +### Requirement: Config Set + +The config command SHALL set configuration values with automatic type coercion. + +#### Scenario: Set string value + +- **WHEN** user executes `openspec config set ` +- **AND** value does not match boolean or number patterns +- **THEN** store value as a string +- **AND** display confirmation message + +#### Scenario: Set boolean value + +- **WHEN** user executes `openspec config set true` or `openspec config set false` +- **THEN** store value as boolean (not string) +- **AND** display confirmation message + +#### Scenario: Set numeric value + +- **WHEN** user executes `openspec config set ` +- **AND** value is a valid number (integer or float) +- **THEN** store value as number (not string) + +#### Scenario: Force string with --string flag + +- **WHEN** user executes `openspec config set --string` +- **THEN** store value as string regardless of content +- **AND** this allows storing literal "true" or "123" as strings + +#### Scenario: Set nested key + +- **WHEN** user executes `openspec config set featureFlags.newFlag true` +- **THEN** create intermediate objects if they don't exist +- **AND** set the value at the nested path + +### Requirement: Config Unset + +The config command SHALL remove configuration overrides. + +#### Scenario: Unset existing key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key exists in the config +- **THEN** remove the key from the config file +- **AND** the value reverts to its default +- **AND** display confirmation message + +#### Scenario: Unset non-existent key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key does not exist in the config +- **THEN** display message indicating key was not set +- **AND** exit with code 0 + +### Requirement: Config Reset + +The config command SHALL reset configuration to defaults. + +#### Scenario: Reset all with confirmation + +- **WHEN** user executes `openspec config reset --all` +- **THEN** prompt for confirmation before proceeding +- **AND** if confirmed, delete the config file or reset to defaults +- **AND** display confirmation message + +#### Scenario: Reset all with -y flag + +- **WHEN** user executes `openspec config reset --all -y` +- **THEN** reset without prompting for confirmation + +#### Scenario: Reset without --all flag + +- **WHEN** user executes `openspec config reset` without `--all` +- **THEN** display error indicating `--all` is required +- **AND** exit with code 1 + +### Requirement: Config Edit + +The config command SHALL open the config file in the user's editor. + +#### Scenario: Open editor successfully + +- **WHEN** user executes `openspec config edit` +- **AND** `$EDITOR` or `$VISUAL` environment variable is set +- **THEN** open the config file in that editor +- **AND** create the config file with defaults if it doesn't exist +- **AND** wait for the editor to close before returning + +#### Scenario: No editor configured + +- **WHEN** user executes `openspec config edit` +- **AND** neither `$EDITOR` nor `$VISUAL` is set +- **THEN** display error message suggesting to set `$EDITOR` +- **AND** exit with code 1 + +### Requirement: Key Naming Convention + +The config command SHALL use camelCase keys matching the JSON structure. + +#### Scenario: Keys match JSON structure + +- **WHEN** accessing configuration keys via CLI +- **THEN** use camelCase matching the actual JSON property names +- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`) + +### Requirement: Schema Validation + +The config command SHALL validate configuration writes against the config schema using zod, while allowing unknown fields for forward compatibility. + +#### Scenario: Unknown key accepted + +- **WHEN** user executes `openspec config set someFutureKey 123` +- **THEN** the value is saved successfully +- **AND** exit with code 0 + +#### Scenario: Invalid feature flag value rejected + +- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean` +- **THEN** display a descriptive error message +- **AND** do not modify the config file +- **AND** exit with code 1 + +### Requirement: Reserved Scope Flag + +The config command SHALL reserve the `--scope` flag for future extensibility. + +#### Scenario: Scope flag defaults to global + +- **WHEN** user executes any config command without `--scope` +- **THEN** operate on global configuration (default behavior) + +#### Scenario: Project scope not yet implemented + +- **WHEN** user executes `openspec config --scope project ` +- **THEN** display error message: "Project-local config is not yet implemented" +- **AND** exit with code 1 diff --git a/openspec/changes/archive/2025-12-21-add-config-command/tasks.md b/openspec/changes/archive/2025-12-21-add-config-command/tasks.md new file mode 100644 index 000000000..572e70e36 --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-config-command/tasks.md @@ -0,0 +1,28 @@ +## 1. Core Infrastructure + +- [x] 1.1 Create zod schema for global config in `src/core/config-schema.ts` +- [x] 1.2 Add utility functions for dot-notation key access (get/set nested values) +- [x] 1.3 Add type coercion logic (auto-detect boolean/number/string) + +## 2. Config Command Implementation + +- [x] 2.1 Create `src/commands/config.ts` with Commander.js subcommands +- [x] 2.2 Implement `config path` subcommand +- [x] 2.3 Implement `config list` subcommand with `--json` flag +- [x] 2.4 Implement `config get ` subcommand (raw output) +- [x] 2.5 Implement `config set ` with `--string` flag +- [x] 2.6 Implement `config unset ` subcommand +- [x] 2.7 Implement `config reset --all` with `-y` confirmation flag +- [x] 2.8 Implement `config edit` subcommand (spawn $EDITOR) + +## 3. Integration + +- [x] 3.1 Register config command in CLI entry point +- [x] 3.2 Update shell completion registry to include config subcommands + +## 4. Testing + +- [x] 4.1 Manual testing of all subcommands +- [x] 4.2 Verify zod validation rejects invalid keys/values +- [x] 4.3 Test nested key access with dot notation +- [x] 4.4 Test type coercion edge cases (true/false, numbers, strings) diff --git a/openspec/specs/cli-config/spec.md b/openspec/specs/cli-config/spec.md new file mode 100644 index 000000000..fb2b8380a --- /dev/null +++ b/openspec/specs/cli-config/spec.md @@ -0,0 +1,217 @@ +# cli-config Specification + +## Purpose +Provide a user-friendly CLI interface for viewing and modifying global OpenSpec configuration settings without manually editing JSON files. +## Requirements +### Requirement: Command Structure + +The config command SHALL provide subcommands for all configuration operations. + +#### Scenario: Available subcommands + +- **WHEN** user executes `openspec config --help` +- **THEN** display available subcommands: + - `path` - Show config file location + - `list` - Show all current settings + - `get ` - Get a specific value + - `set ` - Set a value + - `unset ` - Remove a key (revert to default) + - `reset` - Reset configuration to defaults + - `edit` - Open config in editor + +### Requirement: Config Path + +The config command SHALL display the config file location. + +#### Scenario: Show config path + +- **WHEN** user executes `openspec config path` +- **THEN** print the absolute path to the config file +- **AND** exit with code 0 + +### Requirement: Config List + +The config command SHALL display all current configuration values. + +#### Scenario: List config in human-readable format + +- **WHEN** user executes `openspec config list` +- **THEN** display all config values in YAML-like format +- **AND** show nested objects with indentation + +#### Scenario: List config as JSON + +- **WHEN** user executes `openspec config list --json` +- **THEN** output the complete config as valid JSON +- **AND** output only JSON (no additional text) + +### Requirement: Config Get + +The config command SHALL retrieve specific configuration values. + +#### Scenario: Get top-level key + +- **WHEN** user executes `openspec config get ` with a valid top-level key +- **THEN** print the raw value only (no labels or formatting) +- **AND** exit with code 0 + +#### Scenario: Get nested key with dot notation + +- **WHEN** user executes `openspec config get featureFlags.someFlag` +- **THEN** traverse the nested structure using dot notation +- **AND** print the value at that path + +#### Scenario: Get non-existent key + +- **WHEN** user executes `openspec config get ` with a key that does not exist +- **THEN** print nothing (empty output) +- **AND** exit with code 1 + +#### Scenario: Get object value + +- **WHEN** user executes `openspec config get ` where the value is an object +- **THEN** print the object as JSON + +### Requirement: Config Set + +The config command SHALL set configuration values with automatic type coercion. + +#### Scenario: Set string value + +- **WHEN** user executes `openspec config set ` +- **AND** value does not match boolean or number patterns +- **THEN** store value as a string +- **AND** display confirmation message + +#### Scenario: Set boolean value + +- **WHEN** user executes `openspec config set true` or `openspec config set false` +- **THEN** store value as boolean (not string) +- **AND** display confirmation message + +#### Scenario: Set numeric value + +- **WHEN** user executes `openspec config set ` +- **AND** value is a valid number (integer or float) +- **THEN** store value as number (not string) + +#### Scenario: Force string with --string flag + +- **WHEN** user executes `openspec config set --string` +- **THEN** store value as string regardless of content +- **AND** this allows storing literal "true" or "123" as strings + +#### Scenario: Set nested key + +- **WHEN** user executes `openspec config set featureFlags.newFlag true` +- **THEN** create intermediate objects if they don't exist +- **AND** set the value at the nested path + +### Requirement: Config Unset + +The config command SHALL remove configuration overrides. + +#### Scenario: Unset existing key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key exists in the config +- **THEN** remove the key from the config file +- **AND** the value reverts to its default +- **AND** display confirmation message + +#### Scenario: Unset non-existent key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key does not exist in the config +- **THEN** display message indicating key was not set +- **AND** exit with code 0 + +### Requirement: Config Reset + +The config command SHALL reset configuration to defaults. + +#### Scenario: Reset all with confirmation + +- **WHEN** user executes `openspec config reset --all` +- **THEN** prompt for confirmation before proceeding +- **AND** if confirmed, delete the config file or reset to defaults +- **AND** display confirmation message + +#### Scenario: Reset all with -y flag + +- **WHEN** user executes `openspec config reset --all -y` +- **THEN** reset without prompting for confirmation + +#### Scenario: Reset without --all flag + +- **WHEN** user executes `openspec config reset` without `--all` +- **THEN** display error indicating `--all` is required +- **AND** exit with code 1 + +### Requirement: Config Edit + +The config command SHALL open the config file in the user's editor. + +#### Scenario: Open editor successfully + +- **WHEN** user executes `openspec config edit` +- **AND** `$EDITOR` or `$VISUAL` environment variable is set +- **THEN** open the config file in that editor +- **AND** create the config file with defaults if it doesn't exist +- **AND** wait for the editor to close before returning + +#### Scenario: No editor configured + +- **WHEN** user executes `openspec config edit` +- **AND** neither `$EDITOR` nor `$VISUAL` is set +- **THEN** display error message suggesting to set `$EDITOR` +- **AND** exit with code 1 + +### Requirement: Key Naming Convention + +The config command SHALL use camelCase keys matching the JSON structure. + +#### Scenario: Keys match JSON structure + +- **WHEN** accessing configuration keys via CLI +- **THEN** use camelCase matching the actual JSON property names +- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`) + +### Requirement: Schema Validation + +The config command SHALL validate configuration writes against the config schema using zod, while rejecting unknown keys for `config set` unless explicitly overridden. + +#### Scenario: Unknown key rejected by default + +- **WHEN** user executes `openspec config set someFutureKey 123` +- **THEN** display a descriptive error message indicating the key is invalid +- **AND** do not modify the config file +- **AND** exit with code 1 + +#### Scenario: Unknown key accepted with override + +- **WHEN** user executes `openspec config set someFutureKey 123 --allow-unknown` +- **THEN** the value is saved successfully +- **AND** exit with code 0 + +#### Scenario: Invalid feature flag value rejected + +- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean` +- **THEN** display a descriptive error message +- **AND** do not modify the config file +- **AND** exit with code 1 + +### Requirement: Reserved Scope Flag + +The config command SHALL reserve the `--scope` flag for future extensibility. + +#### Scenario: Scope flag defaults to global + +- **WHEN** user executes any config command without `--scope` +- **THEN** operate on global configuration (default behavior) + +#### Scenario: Project scope not yet implemented + +- **WHEN** user executes `openspec config --scope project ` +- **THEN** display error message: "Project-local config is not yet implemented" +- **AND** exit with code 1 diff --git a/src/cli/index.ts b/src/cli/index.ts index 9ae2c2396..e8cb2f532 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; +import { registerConfigCommand } from '../commands/config.js'; const program = new Command(); const require = createRequire(import.meta.url); @@ -200,6 +201,7 @@ program }); registerSpecCommand(program); +registerConfigCommand(program); // Top-level validate command program diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 000000000..2ee7216cf --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +import { confirm } from '@inquirer/prompts'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import { + getGlobalConfigPath, + getGlobalConfig, + saveGlobalConfig, + GlobalConfig, +} from '../core/global-config.js'; +import { + getNestedValue, + setNestedValue, + deleteNestedValue, + coerceValue, + formatValueYaml, + validateConfigKeyPath, + validateConfig, + DEFAULT_CONFIG, +} from '../core/config-schema.js'; + +/** + * Register the config command and all its subcommands. + * + * @param program - The Commander program instance + */ +export function registerConfigCommand(program: Command): void { + const configCmd = program + .command('config') + .description('View and modify global OpenSpec configuration') + .option('--scope ', 'Config scope (only "global" supported currently)') + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.scope && opts.scope !== 'global') { + console.error('Error: Project-local config is not yet implemented'); + process.exit(1); + } + }); + + // config path + configCmd + .command('path') + .description('Show config file location') + .action(() => { + console.log(getGlobalConfigPath()); + }); + + // config list + configCmd + .command('list') + .description('Show all current settings') + .option('--json', 'Output as JSON') + .action((options: { json?: boolean }) => { + const config = getGlobalConfig(); + + if (options.json) { + console.log(JSON.stringify(config, null, 2)); + } else { + console.log(formatValueYaml(config)); + } + }); + + // config get + configCmd + .command('get ') + .description('Get a specific value (raw, scriptable)') + .action((key: string) => { + const config = getGlobalConfig(); + const value = getNestedValue(config as Record, key); + + if (value === undefined) { + process.exitCode = 1; + return; + } + + if (typeof value === 'object' && value !== null) { + console.log(JSON.stringify(value)); + } else { + console.log(String(value)); + } + }); + + // config set + configCmd + .command('set ') + .description('Set a value (auto-coerce types)') + .option('--string', 'Force value to be stored as string') + .option('--allow-unknown', 'Allow setting unknown keys') + .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { + const allowUnknown = Boolean(options.allowUnknown); + const keyValidation = validateConfigKeyPath(key); + if (!keyValidation.valid && !allowUnknown) { + const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; + console.error(`Error: Invalid configuration key "${key}".${reason}`); + console.error('Use "openspec config list" to see available keys.'); + console.error('Pass --allow-unknown to bypass this check.'); + process.exitCode = 1; + return; + } + + const config = getGlobalConfig() as Record; + const coercedValue = coerceValue(value, options.string || false); + + // Create a copy to validate before saving + const newConfig = JSON.parse(JSON.stringify(config)); + setNestedValue(newConfig, key, coercedValue); + + // Validate the new config + const validation = validateConfig(newConfig); + if (!validation.success) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + return; + } + + // Apply changes and save + setNestedValue(config, key, coercedValue); + saveGlobalConfig(config as GlobalConfig); + + const displayValue = + typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + console.log(`Set ${key} = ${displayValue}`); + }); + + // config unset + configCmd + .command('unset ') + .description('Remove a key (revert to default)') + .action((key: string) => { + const config = getGlobalConfig() as Record; + const existed = deleteNestedValue(config, key); + + if (existed) { + saveGlobalConfig(config as GlobalConfig); + console.log(`Unset ${key} (reverted to default)`); + } else { + console.log(`Key "${key}" was not set`); + } + }); + + // config reset + configCmd + .command('reset') + .description('Reset configuration to defaults') + .option('--all', 'Reset all configuration (required)') + .option('-y, --yes', 'Skip confirmation prompts') + .action(async (options: { all?: boolean; yes?: boolean }) => { + if (!options.all) { + console.error('Error: --all flag is required for reset'); + console.error('Usage: openspec config reset --all [-y]'); + process.exitCode = 1; + return; + } + + if (!options.yes) { + const confirmed = await confirm({ + message: 'Reset all configuration to defaults?', + default: false, + }); + + if (!confirmed) { + console.log('Reset cancelled.'); + return; + } + } + + saveGlobalConfig({ ...DEFAULT_CONFIG }); + console.log('Configuration reset to defaults'); + }); + + // config edit + configCmd + .command('edit') + .description('Open config in $EDITOR') + .action(async () => { + const editor = process.env.EDITOR || process.env.VISUAL; + + if (!editor) { + console.error('Error: No editor configured'); + console.error('Set the EDITOR or VISUAL environment variable to your preferred editor'); + console.error('Example: export EDITOR=vim'); + process.exitCode = 1; + return; + } + + const configPath = getGlobalConfigPath(); + + // Ensure config file exists with defaults + if (!fs.existsSync(configPath)) { + saveGlobalConfig({ ...DEFAULT_CONFIG }); + } + + // Spawn editor and wait for it to close + // Avoid shell parsing to correctly handle paths with spaces in both + // the editor path and config path + const child = spawn(editor, [configPath], { + stdio: 'inherit', + shell: false, + }); + + await new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Editor exited with code ${code}`)); + } + }); + child.on('error', reject); + }); + + try { + const rawConfig = fs.readFileSync(configPath, 'utf-8'); + const parsedConfig = JSON.parse(rawConfig); + const validation = validateConfig(parsedConfig); + + if (!validation.success) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.error(`Error: Config file not found at ${configPath}`); + } else if (error instanceof SyntaxError) { + console.error(`Error: Invalid JSON in ${configPath}`); + console.error(error.message); + } else { + console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`); + } + process.exitCode = 1; + } + }); +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index fc4b67ea6..f0898ec9f 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -288,4 +288,77 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'config', + description: 'View and modify global OpenSpec configuration', + flags: [ + { + name: 'scope', + description: 'Config scope (only "global" supported currently)', + takesValue: true, + values: ['global'], + }, + ], + subcommands: [ + { + name: 'path', + description: 'Show config file location', + flags: [], + }, + { + name: 'list', + description: 'Show all current settings', + flags: [ + COMMON_FLAGS.json, + ], + }, + { + name: 'get', + description: 'Get a specific value (raw, scriptable)', + acceptsPositional: true, + flags: [], + }, + { + name: 'set', + description: 'Set a value (auto-coerce types)', + acceptsPositional: true, + flags: [ + { + name: 'string', + description: 'Force value to be stored as string', + }, + { + name: 'allow-unknown', + description: 'Allow setting unknown keys', + }, + ], + }, + { + name: 'unset', + description: 'Remove a key (revert to default)', + acceptsPositional: true, + flags: [], + }, + { + name: 'reset', + description: 'Reset configuration to defaults', + flags: [ + { + name: 'all', + description: 'Reset all configuration (required)', + }, + { + name: 'yes', + short: 'y', + description: 'Skip confirmation prompts', + }, + ], + }, + { + name: 'edit', + description: 'Open config in $EDITOR', + flags: [], + }, + ], + }, ]; diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts new file mode 100644 index 000000000..78d27b48b --- /dev/null +++ b/src/core/config-schema.ts @@ -0,0 +1,230 @@ +import { z } from 'zod'; + +/** + * Zod schema for global OpenSpec configuration. + * Uses passthrough() to preserve unknown fields for forward compatibility. + */ +export const GlobalConfigSchema = z + .object({ + featureFlags: z + .record(z.string(), z.boolean()) + .optional() + .default({}), + }) + .passthrough(); + +export type GlobalConfigType = z.infer; + +/** + * Default configuration values. + */ +export const DEFAULT_CONFIG: GlobalConfigType = { + featureFlags: {}, +}; + +const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG)); + +/** + * Validate a config key path for CLI set operations. + * Unknown top-level keys are rejected unless explicitly allowed by the caller. + */ +export function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } { + const rawKeys = path.split('.'); + + if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) { + return { valid: false, reason: 'Key path must not be empty' }; + } + + const rootKey = rawKeys[0]; + if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) { + return { valid: false, reason: `Unknown top-level key "${rootKey}"` }; + } + + if (rootKey === 'featureFlags') { + if (rawKeys.length > 2) { + return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' }; + } + return { valid: true }; + } + + if (rawKeys.length > 1) { + return { valid: false, reason: `"${rootKey}" does not support nested keys` }; + } + + return { valid: true }; +} + +/** + * Get a nested value from an object using dot notation. + * + * @param obj - The object to access + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @returns The value at the path, or undefined if not found + */ +export function getNestedValue(obj: Record, path: string): unknown { + const keys = path.split('.'); + let current: unknown = obj; + + for (const key of keys) { + if (current === null || current === undefined) { + return undefined; + } + if (typeof current !== 'object') { + return undefined; + } + current = (current as Record)[key]; + } + + return current; +} + +/** + * Set a nested value in an object using dot notation. + * Creates intermediate objects as needed. + * + * @param obj - The object to modify (mutated in place) + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @param value - The value to set + */ +export function setNestedValue(obj: Record, path: string, value: unknown): void { + const keys = path.split('.'); + let current: Record = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + + const lastKey = keys[keys.length - 1]; + current[lastKey] = value; +} + +/** + * Delete a nested value from an object using dot notation. + * + * @param obj - The object to modify (mutated in place) + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @returns true if the key existed and was deleted, false otherwise + */ +export function deleteNestedValue(obj: Record, path: string): boolean { + const keys = path.split('.'); + let current: Record = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') { + return false; + } + current = current[key] as Record; + } + + const lastKey = keys[keys.length - 1]; + if (lastKey in current) { + delete current[lastKey]; + return true; + } + return false; +} + +/** + * Coerce a string value to its appropriate type. + * - "true" / "false" -> boolean + * - Numeric strings -> number + * - Everything else -> string + * + * @param value - The string value to coerce + * @param forceString - If true, always return the value as a string + * @returns The coerced value + */ +export function coerceValue(value: string, forceString: boolean = false): string | number | boolean { + if (forceString) { + return value; + } + + // Boolean coercion + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + + // Number coercion - must be a valid finite number + const num = Number(value); + if (!isNaN(num) && isFinite(num) && value.trim() !== '') { + return num; + } + + return value; +} + +/** + * Format a value for YAML-like display. + * + * @param value - The value to format + * @param indent - Current indentation level + * @returns Formatted string + */ +export function formatValueYaml(value: unknown, indent: number = 0): string { + const indentStr = ' '.repeat(indent); + + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]'; + } + return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\n'); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return '{}'; + } + return entries + .map(([key, val]) => { + const formattedVal = formatValueYaml(val, indent + 1); + if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) { + return `${indentStr}${key}:\n${formattedVal}`; + } + return `${indentStr}${key}: ${formattedVal}`; + }) + .join('\n'); + } + + return String(value); +} + +/** + * Validate a configuration object against the schema. + * + * @param config - The configuration to validate + * @returns Validation result with success status and optional error message + */ +export function validateConfig(config: unknown): { success: boolean; error?: string } { + try { + GlobalConfigSchema.parse(config); + return { success: true }; + } catch (error) { + if (error instanceof z.ZodError) { + const zodError = error as z.ZodError; + const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`); + return { success: false, error: messages.join('; ') }; + } + return { success: false, error: 'Unknown validation error' }; + } +} diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts new file mode 100644 index 000000000..e6880c924 --- /dev/null +++ b/test/commands/config.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('config command integration', () => { + // These tests use real file system operations with XDG_CONFIG_HOME override + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // Create unique temp directory for each test + tempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + + // Save original env and set XDG_CONFIG_HOME + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = tempDir; + + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + + // Clean up temp directory + fs.rmSync(tempDir, { recursive: true, force: true }); + + // Restore spies + consoleErrorSpy.mockRestore(); + + // Reset module cache to pick up new XDG_CONFIG_HOME + vi.resetModules(); + }); + + it('should use XDG_CONFIG_HOME for config path', async () => { + const { getGlobalConfigPath } = await import('../../src/core/global-config.js'); + const configPath = getGlobalConfigPath(); + expect(configPath).toBe(path.join(tempDir, 'openspec', 'config.json')); + }); + + it('should save and load config correctly', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + + saveGlobalConfig({ featureFlags: { test: true } }); + const config = getGlobalConfig(); + expect(config.featureFlags).toEqual({ test: true }); + }); + + it('should return defaults when config file does not exist', async () => { + const { getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); + + const configPath = getGlobalConfigPath(); + // Make sure config doesn't exist + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + + const config = getGlobalConfig(); + expect(config.featureFlags).toEqual({}); + }); + + it('should preserve unknown fields', async () => { + const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js'); + + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + featureFlags: {}, + customField: 'preserved', + })); + + const config = getGlobalConfig(); + expect((config as Record).customField).toBe('preserved'); + }); + + it('should handle invalid JSON gracefully', async () => { + const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js'); + + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), '{ invalid json }'); + + const config = getGlobalConfig(); + // Should return defaults + expect(config.featureFlags).toEqual({}); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON')); + }); +}); + +describe('config command shell completion registry', () => { + it('should have config command in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + expect(configCmd).toBeDefined(); + expect(configCmd?.description).toBe('View and modify global OpenSpec configuration'); + }); + + it('should have all config subcommands in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const subcommandNames = configCmd?.subcommands?.map((s) => s.name) ?? []; + + expect(subcommandNames).toContain('path'); + expect(subcommandNames).toContain('list'); + expect(subcommandNames).toContain('get'); + expect(subcommandNames).toContain('set'); + expect(subcommandNames).toContain('unset'); + expect(subcommandNames).toContain('reset'); + expect(subcommandNames).toContain('edit'); + }); + + it('should have --json flag on list subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const listCmd = configCmd?.subcommands?.find((s) => s.name === 'list'); + const flagNames = listCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('json'); + }); + + it('should have --string flag on set subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const setCmd = configCmd?.subcommands?.find((s) => s.name === 'set'); + const flagNames = setCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('string'); + expect(flagNames).toContain('allow-unknown'); + }); + + it('should have --all and -y flags on reset subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const resetCmd = configCmd?.subcommands?.find((s) => s.name === 'reset'); + const flagNames = resetCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('all'); + expect(flagNames).toContain('yes'); + }); + + it('should have --scope flag on config command', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const flagNames = configCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('scope'); + }); +}); + +describe('config key validation', () => { + it('rejects unknown top-level keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('unknownKey').valid).toBe(false); + }); + + it('allows feature flag keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('featureFlags.someFlag').valid).toBe(true); + }); + + it('rejects deeply nested feature flag keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('featureFlags.someFlag.extra').valid).toBe(false); + }); +}); diff --git a/test/core/config-schema.test.ts b/test/core/config-schema.test.ts new file mode 100644 index 000000000..eeff81ccc --- /dev/null +++ b/test/core/config-schema.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect } from 'vitest'; + +import { + getNestedValue, + setNestedValue, + deleteNestedValue, + coerceValue, + formatValueYaml, + validateConfig, + GlobalConfigSchema, + DEFAULT_CONFIG, +} from '../../src/core/config-schema.js'; + +describe('config-schema', () => { + describe('getNestedValue', () => { + it('should get a top-level value', () => { + const obj = { foo: 'bar' }; + expect(getNestedValue(obj, 'foo')).toBe('bar'); + }); + + it('should get a nested value with dot notation', () => { + const obj = { a: { b: { c: 'deep' } } }; + expect(getNestedValue(obj, 'a.b.c')).toBe('deep'); + }); + + it('should return undefined for non-existent path', () => { + const obj = { foo: 'bar' }; + expect(getNestedValue(obj, 'baz')).toBeUndefined(); + }); + + it('should return undefined for non-existent nested path', () => { + const obj = { a: { b: 'value' } }; + expect(getNestedValue(obj, 'a.b.c')).toBeUndefined(); + }); + + it('should return undefined when traversing through null', () => { + const obj = { a: null }; + expect(getNestedValue(obj as Record, 'a.b')).toBeUndefined(); + }); + + it('should return undefined when traversing through primitive', () => { + const obj = { a: 'string' }; + expect(getNestedValue(obj, 'a.b')).toBeUndefined(); + }); + + it('should get object values', () => { + const obj = { a: { b: 'value' } }; + expect(getNestedValue(obj, 'a')).toEqual({ b: 'value' }); + }); + + it('should handle array values', () => { + const obj = { arr: [1, 2, 3] }; + expect(getNestedValue(obj, 'arr')).toEqual([1, 2, 3]); + }); + }); + + describe('setNestedValue', () => { + it('should set a top-level value', () => { + const obj: Record = {}; + setNestedValue(obj, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('should set a nested value', () => { + const obj: Record = {}; + setNestedValue(obj, 'a.b.c', 'deep'); + expect((obj.a as Record).b).toEqual({ c: 'deep' }); + }); + + it('should create intermediate objects', () => { + const obj: Record = {}; + setNestedValue(obj, 'x.y.z', 'value'); + expect(obj).toEqual({ x: { y: { z: 'value' } } }); + }); + + it('should overwrite existing values', () => { + const obj: Record = { a: 'old' }; + setNestedValue(obj, 'a', 'new'); + expect(obj.a).toBe('new'); + }); + + it('should overwrite primitive with object when setting nested path', () => { + const obj: Record = { a: 'string' }; + setNestedValue(obj, 'a.b', 'value'); + expect(obj.a).toEqual({ b: 'value' }); + }); + + it('should preserve other keys when setting nested value', () => { + const obj: Record = { a: { existing: 'keep' } }; + setNestedValue(obj, 'a.new', 'added'); + expect(obj.a).toEqual({ existing: 'keep', new: 'added' }); + }); + }); + + describe('deleteNestedValue', () => { + it('should delete a top-level key', () => { + const obj: Record = { foo: 'bar', baz: 'qux' }; + const result = deleteNestedValue(obj, 'foo'); + expect(result).toBe(true); + expect(obj).toEqual({ baz: 'qux' }); + }); + + it('should delete a nested key', () => { + const obj: Record = { a: { b: 'value', c: 'keep' } }; + const result = deleteNestedValue(obj, 'a.b'); + expect(result).toBe(true); + expect(obj.a).toEqual({ c: 'keep' }); + }); + + it('should return false for non-existent key', () => { + const obj: Record = { foo: 'bar' }; + const result = deleteNestedValue(obj, 'baz'); + expect(result).toBe(false); + }); + + it('should return false for non-existent nested path', () => { + const obj: Record = { a: { b: 'value' } }; + const result = deleteNestedValue(obj, 'a.c'); + expect(result).toBe(false); + }); + + it('should return false when intermediate path does not exist', () => { + const obj: Record = { a: 'string' }; + const result = deleteNestedValue(obj, 'a.b.c'); + expect(result).toBe(false); + }); + }); + + describe('coerceValue', () => { + it('should coerce "true" to boolean true', () => { + expect(coerceValue('true')).toBe(true); + }); + + it('should coerce "false" to boolean false', () => { + expect(coerceValue('false')).toBe(false); + }); + + it('should coerce integer string to number', () => { + expect(coerceValue('42')).toBe(42); + }); + + it('should coerce float string to number', () => { + expect(coerceValue('3.14')).toBe(3.14); + }); + + it('should coerce negative number string to number', () => { + expect(coerceValue('-10')).toBe(-10); + }); + + it('should keep regular strings as strings', () => { + expect(coerceValue('hello')).toBe('hello'); + }); + + it('should keep strings that start with numbers but are not numbers', () => { + expect(coerceValue('123abc')).toBe('123abc'); + }); + + it('should keep empty string as string', () => { + expect(coerceValue('')).toBe(''); + }); + + it('should keep whitespace-only string as string', () => { + expect(coerceValue(' ')).toBe(' '); + }); + + it('should force string when forceString is true', () => { + expect(coerceValue('true', true)).toBe('true'); + expect(coerceValue('42', true)).toBe('42'); + expect(coerceValue('hello', true)).toBe('hello'); + }); + + it('should not coerce Infinity to number (not finite)', () => { + // Infinity is not a useful config value, so we keep it as string + expect(coerceValue('Infinity')).toBe('Infinity'); + }); + + it('should handle scientific notation', () => { + expect(coerceValue('1e10')).toBe(1e10); + }); + }); + + describe('formatValueYaml', () => { + it('should format null as "null"', () => { + expect(formatValueYaml(null)).toBe('null'); + }); + + it('should format undefined as "null"', () => { + expect(formatValueYaml(undefined)).toBe('null'); + }); + + it('should format boolean as string', () => { + expect(formatValueYaml(true)).toBe('true'); + expect(formatValueYaml(false)).toBe('false'); + }); + + it('should format number as string', () => { + expect(formatValueYaml(42)).toBe('42'); + expect(formatValueYaml(3.14)).toBe('3.14'); + }); + + it('should format string as-is', () => { + expect(formatValueYaml('hello')).toBe('hello'); + }); + + it('should format empty array as "[]"', () => { + expect(formatValueYaml([])).toBe('[]'); + }); + + it('should format empty object as "{}"', () => { + expect(formatValueYaml({})).toBe('{}'); + }); + + it('should format object with key-value pairs', () => { + const result = formatValueYaml({ foo: 'bar' }); + expect(result).toBe('foo: bar'); + }); + + it('should format nested objects with indentation', () => { + const result = formatValueYaml({ a: { b: 'value' } }); + expect(result).toContain('a:'); + expect(result).toContain('b: value'); + }); + }); + + describe('validateConfig', () => { + it('should accept valid config with featureFlags', () => { + const result = validateConfig({ featureFlags: { test: true } }); + expect(result.success).toBe(true); + }); + + it('should accept empty featureFlags', () => { + const result = validateConfig({ featureFlags: {} }); + expect(result.success).toBe(true); + }); + + it('should accept config without featureFlags (uses default)', () => { + const result = validateConfig({}); + expect(result.success).toBe(true); + }); + + it('should accept unknown fields (passthrough)', () => { + const result = validateConfig({ featureFlags: {}, unknownField: 'value' }); + expect(result.success).toBe(true); + }); + + it('should accept unknown fields with various types', () => { + const result = validateConfig({ + featureFlags: {}, + futureStringField: 'value', + futureNumberField: 123, + futureObjectField: { nested: 'data' }, + }); + expect(result.success).toBe(true); + }); + + it('should reject non-boolean values in featureFlags', () => { + const result = validateConfig({ featureFlags: { test: 'string' } }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should include path in error message for invalid featureFlags', () => { + const result = validateConfig({ featureFlags: { someFlag: 'notABoolean' } }); + expect(result.success).toBe(false); + expect(result.error).toContain('featureFlags'); + }); + + it('should reject non-object featureFlags', () => { + const result = validateConfig({ featureFlags: 'string' }); + expect(result.success).toBe(false); + }); + + it('should reject number values in featureFlags', () => { + const result = validateConfig({ featureFlags: { flag: 123 } }); + expect(result.success).toBe(false); + }); + }); + + describe('config set simulation', () => { + // These tests simulate the full config set flow: coerce value → set nested → validate + + it('should accept setting unknown top-level key (forward compatibility)', () => { + const config: Record = { featureFlags: {} }; + const value = coerceValue('123'); + setNestedValue(config, 'someFutureKey', value); + + const result = validateConfig(config); + expect(result.success).toBe(true); + expect(config.someFutureKey).toBe(123); + }); + + it('should reject setting non-boolean to featureFlags', () => { + const config: Record = { featureFlags: {} }; + const value = coerceValue('notABoolean'); // stays as string + setNestedValue(config, 'featureFlags.someFlag', value); + + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.error).toContain('featureFlags'); + }); + + it('should accept setting boolean to featureFlags', () => { + const config: Record = { featureFlags: {} }; + const value = coerceValue('true'); // coerces to boolean + setNestedValue(config, 'featureFlags.newFlag', value); + + const result = validateConfig(config); + expect(result.success).toBe(true); + expect((config.featureFlags as Record).newFlag).toBe(true); + }); + + it('should create featureFlags object when setting nested flag', () => { + const config: Record = {}; + const value = coerceValue('false'); + setNestedValue(config, 'featureFlags.experimental', value); + + const result = validateConfig(config); + expect(result.success).toBe(true); + expect((config.featureFlags as Record).experimental).toBe(false); + }); + }); + + describe('GlobalConfigSchema', () => { + it('should parse valid config', () => { + const result = GlobalConfigSchema.safeParse({ featureFlags: { test: true } }); + expect(result.success).toBe(true); + }); + + it('should provide defaults for missing featureFlags', () => { + const result = GlobalConfigSchema.parse({}); + expect(result.featureFlags).toEqual({}); + }); + }); + + describe('DEFAULT_CONFIG', () => { + it('should have empty featureFlags', () => { + expect(DEFAULT_CONFIG.featureFlags).toEqual({}); + }); + }); +}); From 2e71835d233f89eb0f433786ee512f76a2c1d725 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:35:29 +1100 Subject: [PATCH 02/12] Add changeset for config command and shell completions (#388) --- .changeset/config-and-completions.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/config-and-completions.md diff --git a/.changeset/config-and-completions.md b/.changeset/config-and-completions.md new file mode 100644 index 000000000..39107b292 --- /dev/null +++ b/.changeset/config-and-completions.md @@ -0,0 +1,18 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features +- Add `openspec config` command for managing global configuration settings +- Implement global config directory with XDG Base Directory specification support +- Add Oh-my-zsh shell completions support for enhanced CLI experience + +### Bug Fixes +- Fix hang in pre-commit hooks by using dynamic imports +- Respect XDG_CONFIG_HOME environment variable on all platforms +- Resolve Windows compatibility issues in zsh-installer tests +- Align cli-completion spec with implementation +- Remove hardcoded agent field from slash commands + +### Documentation +- Alphabetize AI tools list in README and make it collapsible From c2a1a4c807d93c9bb30998efe47321fd9e9f1e53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:44:57 +1100 Subject: [PATCH 03/12] chore(release): version packages (#389) * Version Packages * chore: trigger CI --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tabish Bidiwale --- .changeset/config-and-completions.md | 18 ------------------ CHANGELOG.md | 22 ++++++++++++++++++++++ package.json | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 .changeset/config-and-completions.md diff --git a/.changeset/config-and-completions.md b/.changeset/config-and-completions.md deleted file mode 100644 index 39107b292..000000000 --- a/.changeset/config-and-completions.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@fission-ai/openspec": minor ---- - -### New Features -- Add `openspec config` command for managing global configuration settings -- Implement global config directory with XDG Base Directory specification support -- Add Oh-my-zsh shell completions support for enhanced CLI experience - -### Bug Fixes -- Fix hang in pre-commit hooks by using dynamic imports -- Respect XDG_CONFIG_HOME environment variable on all platforms -- Resolve Windows compatibility issues in zsh-installer tests -- Align cli-completion spec with implementation -- Remove hardcoded agent field from slash commands - -### Documentation -- Alphabetize AI tools list in README and make it collapsible diff --git a/CHANGELOG.md b/CHANGELOG.md index fc572d49c..4a047360f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # @fission-ai/openspec +## 0.17.0 + +### Minor Changes + +- 2e71835: ### New Features + + - Add `openspec config` command for managing global configuration settings + - Implement global config directory with XDG Base Directory specification support + - Add Oh-my-zsh shell completions support for enhanced CLI experience + + ### Bug Fixes + + - Fix hang in pre-commit hooks by using dynamic imports + - Respect XDG_CONFIG_HOME environment variable on all platforms + - Resolve Windows compatibility issues in zsh-installer tests + - Align cli-completion spec with implementation + - Remove hardcoded agent field from slash commands + + ### Documentation + + - Alphabetize AI tools list in README and make it collapsible + ## 0.16.0 ### Minor Changes diff --git a/package.json b/package.json index 9ad409fe5..10be7f43b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fission-ai/openspec", - "version": "0.16.0", + "version": "0.17.0", "description": "AI-native system for spec-driven development", "keywords": [ "openspec", From 6de04f3b2b3656102940817e909ad3963417a797 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:10:27 +1100 Subject: [PATCH 04/12] feat(ci): migrate to npm OIDC trusted publishing (#390) Replace classic npm token authentication with OIDC trusted publishing: - Add `id-token: write` permission for OIDC token generation - Upgrade to Node 24 (includes npm 11.5.1+ required for OIDC) - Remove NPM_TOKEN/NODE_AUTH_TOKEN env vars (OIDC replaces them) This eliminates the need for rotating npm access tokens and provides cryptographically verified publisher identity with automatic provenance attestation. Requires configuring trusted publisher on npmjs.com: - Organization: Fission-AI - Repository: OpenSpec - Workflow: release-prepare.yml --- .github/workflows/release-prepare.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 2dc30d1da..055f4666a 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -7,6 +7,7 @@ on: permissions: contents: write pull-requests: write + id-token: write # Required for npm OIDC trusted publishing concurrency: group: release-${{ github.ref }} @@ -27,11 +28,9 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC cache: 'pnpm' registry-url: 'https://registry.npmjs.org' - scope: '@fission-ai' - always-auth: true - run: pnpm install --frozen-lockfile @@ -46,5 +45,4 @@ jobs: publish: pnpm run release:ci env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # npm authentication handled via OIDC trusted publishing (no token needed) From 6d84924c18db3f171118f2506906ef4a50683f56 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:50:54 +1100 Subject: [PATCH 05/12] fix(cli): use dynamic import for @inquirer/prompts in config command (#392) * fix(cli): use dynamic import for @inquirer/prompts in config command The config command (added in #382) reintroduced the pre-commit hook hang issue that was fixed in #380. The static import of @inquirer/prompts at module load time causes stdin event listeners to be registered even when running non-interactive commands, preventing clean process exit when stdin is piped (as pre-commit does). Convert the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively. Fixes #367 * chore: add ESLint with no-restricted-imports rule for @inquirer Add ESLint configuration that prevents static imports of @inquirer/* modules. This prevents future regressions of the pre-commit hook hang issue fixed in this PR. The rule shows a helpful error message pointing to issue #367 for context. init.ts is exempted since it's already dynamically imported from the CLI. * ci: add ESLint step to lint job Run `pnpm lint` in CI to enforce the no-restricted-imports rule that prevents static @inquirer imports. --- .github/workflows/ci.yml | 3 + eslint.config.js | 42 +++ package.json | 3 + pnpm-lock.yaml | 762 +++++++++++++++++++++++++++++++++++++++ src/commands/config.ts | 2 +- 5 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 eslint.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999c6b1d5..ff1eae8f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..b5437a91f --- /dev/null +++ b/eslint.config.js @@ -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'], + } +); diff --git a/package.json b/package.json index 10be7f43b..1b07886b4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3868d20e0..6661eed55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,9 +36,15 @@ importers: '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + eslint: + specifier: ^9.39.2 + version: 9.39.2 typescript: specifier: ^5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.50.1 + version: 8.50.1(eslint@9.39.2)(typescript@5.9.3) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4) @@ -260,6 +266,60 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inquirer/ansi@1.0.0': resolution: {integrity: sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==} engines: {node: '>=18'} @@ -527,12 +587,74 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@24.2.0': resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} + '@typescript-eslint/eslint-plugin@8.50.1': + resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.50.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.50.1': + resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.50.1': + resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.50.1': + resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.50.1': + resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.50.1': + resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.50.1': + resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.50.1': + resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.50.1': + resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.50.1': + resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -567,6 +689,19 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -590,6 +725,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -598,10 +736,19 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -610,10 +757,18 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + chai@5.2.1: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.5.0: resolution: {integrity: sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -655,6 +810,9 @@ packages: resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} engines: {node: '>=20'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -672,6 +830,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -698,14 +859,60 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -717,10 +924,19 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -732,9 +948,22 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -743,6 +972,14 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -767,6 +1004,14 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -774,6 +1019,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + human-id@4.1.1: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true @@ -790,6 +1039,18 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -836,13 +1097,40 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -868,6 +1156,13 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -888,10 +1183,17 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -911,10 +1213,18 @@ packages: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-map@2.1.0: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} @@ -926,6 +1236,10 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -964,11 +1278,19 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -979,6 +1301,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1070,9 +1396,17 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -1087,6 +1421,10 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1111,10 +1449,27 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + typescript-eslint@8.50.1: + resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1127,6 +1482,9 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1210,10 +1568,18 @@ packages: engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} @@ -1447,6 +1813,63 @@ snapshots: '@esbuild/win32-x64@0.25.8': optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2)': + dependencies: + eslint: 9.39.2 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@1.0.0': {} '@inquirer/checkbox@4.2.0(@types/node@24.2.0)': @@ -1672,12 +2095,105 @@ snapshots: '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/node@12.20.55': {} '@types/node@24.2.0': dependencies: undici-types: 7.10.0 + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 + eslint: 9.39.2 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.1 + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 + debug: 4.4.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + + '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + debug: 4.4.1 + eslint: 9.39.2 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.50.1': {} + + '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.1 + minimatch: 9.0.5 + semver: 7.7.2 + tinyglobby: 0.2.15 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.50.1(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + eslint-visitor-keys: 4.2.1 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -1731,6 +2247,19 @@ snapshots: loupe: 3.2.0 tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -1749,20 +2278,35 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + array-union@2.1.0: {} assertion-error@2.0.1: {} + balanced-match@1.0.2: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 cac@6.7.14: {} + callsites@3.1.0: {} + chai@5.2.1: dependencies: assertion-error: 2.0.1 @@ -1771,6 +2315,11 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.5.0: {} chardet@0.7.0: {} @@ -1797,6 +2346,8 @@ snapshots: commander@14.0.0: {} + concat-map@0.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1809,6 +2360,8 @@ snapshots: deep-eql@5.0.2: {} + deep-is@0.1.4: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -1855,12 +2408,80 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.2: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.2.2: {} extendable-error@0.1.7: {} @@ -1871,6 +2492,8 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1879,6 +2502,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -1887,8 +2514,16 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -1898,6 +2533,16 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + flatted@3.3.3: {} fs-extra@7.0.1: @@ -1921,6 +2566,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -1932,6 +2583,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + human-id@4.1.1: {} iconv-lite@0.4.24: @@ -1944,6 +2597,15 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -1975,14 +2637,39 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + lodash.startcase@4.4.0: {} log-symbols@6.0.0: @@ -2005,6 +2692,14 @@ snapshots: mimic-function@5.0.1: {} + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + mri@1.2.0: {} mrmime@2.0.1: {} @@ -2015,10 +2710,21 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@8.2.0: dependencies: chalk: 5.5.0 @@ -2043,10 +2749,18 @@ snapshots: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-map@2.1.0: {} p-try@2.2.0: {} @@ -2055,6 +2769,10 @@ snapshots: dependencies: quansync: 0.2.11 + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2079,8 +2797,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + prettier@2.8.8: {} + punycode@2.3.1: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -2092,6 +2814,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} restore-cursor@5.1.0: @@ -2190,10 +2914,16 @@ snapshots: strip-bom@3.0.0: {} + strip-json-comments@3.1.1: {} + strip-literal@3.0.0: dependencies: js-tokens: 9.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -2205,6 +2935,11 @@ snapshots: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} @@ -2221,14 +2956,37 @@ snapshots: totalist@3.0.1: {} + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.21.3: {} + typescript-eslint@8.50.1(eslint@9.39.2)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.10.0: {} universalify@0.1.2: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + vite-node@3.2.4(@types/node@24.2.0): dependencies: cac: 6.7.14 @@ -2313,12 +3071,16 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.2: {} zod@4.0.17: {} diff --git a/src/commands/config.ts b/src/commands/config.ts index 2ee7216cf..3df9f852d 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -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, From a2757e78560a2bc6ed281a91ebf689ae23db5eda Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:56:51 +1100 Subject: [PATCH 06/12] Add changeset for config command dynamic import fix (#393) --- .changeset/config-dynamic-import.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/config-dynamic-import.md diff --git a/.changeset/config-dynamic-import.md b/.changeset/config-dynamic-import.md new file mode 100644 index 000000000..b216e49be --- /dev/null +++ b/.changeset/config-dynamic-import.md @@ -0,0 +1,9 @@ +--- +"@fission-ai/openspec": patch +--- + +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. From fb264bcbcd154d92207e58e1127a14f8a19ebb0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:00:01 +1100 Subject: [PATCH 07/12] chore(release): version packages (#394) * Version Packages * chore: trigger CI --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tabish Bidiwale --- .changeset/config-dynamic-import.md | 9 --------- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 .changeset/config-dynamic-import.md diff --git a/.changeset/config-dynamic-import.md b/.changeset/config-dynamic-import.md deleted file mode 100644 index b216e49be..000000000 --- a/.changeset/config-dynamic-import.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@fission-ai/openspec": patch ---- - -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a047360f..6e10302bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 1b07886b4..4c0be0638 100644 --- a/package.json +++ b/package.json @@ -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", From 9ac63304306aa3835583d527c4d41916a3ddbe76 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:42:25 +1100 Subject: [PATCH 08/12] 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. --- src/commands/validate.ts | 9 +-- src/utils/interactive.ts | 11 ++- test/commands/validate.test.ts | 14 ++++ test/utils/interactive.test.ts | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 test/utils/interactive.test.ts diff --git a/src/commands/validate.ts b/src/commands/validate.ts index a5323a640..9e59a4d48 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -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 { - 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 { + const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ scope.changes ? getActiveChangeIds() : Promise.resolve([]), scope.specs ? getSpecIds() : Promise.resolve([]), diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts index 3b73fa3b4..aeb9fde9a 100644 --- a/src/utils/interactive.ts +++ b/src/utils/interactive.ts @@ -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; } diff --git a/test/commands/validate.test.ts b/test/commands/validate.test.ts index 9e67a34bb..b94f72d35 100644 --- a/test/commands/validate.test.ts +++ b/test/commands/validate.test.ts @@ -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?'); + }); }); diff --git a/test/utils/interactive.test.ts b/test/utils/interactive.test.ts new file mode 100644 index 000000000..c1753d31d --- /dev/null +++ b/test/utils/interactive.test.ts @@ -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); + }); + }); +}); From 455c65f3c424de6c465157dfa9f8ac4281072772 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:45:56 +1100 Subject: [PATCH 09/12] Add changeset for --no-interactive flag fix (#396) --- .changeset/fix-no-interactive.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-no-interactive.md diff --git a/.changeset/fix-no-interactive.md b/.changeset/fix-no-interactive.md new file mode 100644 index 000000000..93e4f1c17 --- /dev/null +++ b/.changeset/fix-no-interactive.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments From c08a53cb2125128bd151ce9c19f0f3e53c20d97c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:59:10 +1100 Subject: [PATCH 10/12] chore(release): version packages (#397) * Version Packages * chore: trigger CI --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tabish Bidiwale --- .changeset/fix-no-interactive.md | 5 ----- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fix-no-interactive.md diff --git a/.changeset/fix-no-interactive.md b/.changeset/fix-no-interactive.md deleted file mode 100644 index 93e4f1c17..000000000 --- a/.changeset/fix-no-interactive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@fission-ai/openspec": patch ---- - -Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e10302bd..44abebb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @fission-ai/openspec +## 0.17.2 + +### Patch Changes + +- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments + ## 0.17.1 ### Patch Changes diff --git a/package.json b/package.json index 4c0be0638..486873d55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fission-ai/openspec", - "version": "0.17.1", + "version": "0.17.2", "description": "AI-native system for spec-driven development", "keywords": [ "openspec", From 2c2599b1f0587a12680da2a8f07a471ee3c35eed Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:21:11 +1100 Subject: [PATCH 11/12] docs: add artifact POC analysis document (#398) Add internal documentation for the artifact-based approach to OpenSpec core. This document outlines design decisions, terminology, and the philosophy behind treating dependencies as enablers rather than gates. --- .gitignore | 2 - docs/artifact_poc.md | 530 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 docs/artifact_poc.md diff --git a/.gitignore b/.gitignore index eec37bbc3..5ecf229d4 100644 --- a/.gitignore +++ b/.gitignore @@ -140,8 +140,6 @@ dist/ vite.config.js.timestamp-* vite.config.ts.timestamp-* -# Internal Docs -docs/ # Claude .claude/ diff --git a/docs/artifact_poc.md b/docs/artifact_poc.md new file mode 100644 index 000000000..55020233a --- /dev/null +++ b/docs/artifact_poc.md @@ -0,0 +1,530 @@ +# POC-OpenSpec-Core Analysis + +--- + +## Design Decisions & Terminology + +### Philosophy: Not a Workflow System + +This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**. + +| What it's NOT | What it IS | +|---------------|------------| +| Linear step-by-step progression | Exploratory, iterative planning | +| Bureaucratic checkpoints | Enablers that unlock possibilities | +| "You must complete step 1 first" | "Here's what you could create now" | +| Form-filling | Fluid document creation | + +**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic. + +### Terminology + +| Term | Definition | Example | +|------|------------|---------| +| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` | +| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `schemas/spec-driven.yaml` | +| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` | +| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` | + +### Hierarchy + +``` +Schema (defines) ──→ Artifacts (guided by) ──→ Templates +``` + +- **Schema** = the artifact graph (what exists, dependencies) +- **Artifact** = a document to produce +- **Template** = instructions for creating that artifact + +### Schema Variations + +Schemas can vary across multiple dimensions: + +| Dimension | Examples | +|-----------|----------| +| Philosophy | `spec-driven`, `tdd`, `prototype-first` | +| Version | `v1`, `v2`, `v3` | +| Language | `en`, `zh`, `es` | +| Custom | `team-alpha`, `experimental` | + +### Template Inheritance (2 Levels Max) + +``` +.openspec/ +├── templates/ # Shared (Level 1) +│ ├── proposal.md +│ ├── design.md +│ └── specs.md +│ +└── schemas/ + └── tdd/ + ├── schema.yaml + └── templates/ # Overrides (Level 2) + └── tests.md # TDD-specific +``` + +**Rules:** +- Shared templates are the default +- Schema-specific templates override OR add new +- A CLI command shows resolved paths (no guessing) +- No inheritance between schemas (copy if you need to diverge) +- Max 2 levels - no deeper inheritance chains + +**Why this matters:** +- Avoids "where does this come from?" debugging +- No implicit magic that works until it doesn't +- Clear boundaries between shared and specific + +--- + +## Executive Summary + +This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly. + +The system answers: +- "What artifacts exist for this change?" +- "What could I create next?" (not "what must I create") +- "What's blocking X?" (informational, not prescriptive) + +--- + +## Core Components + +### 1. ArtifactGraph + +The dependency graph engine. + +| Responsibility | Approach | +|----------------|----------| +| Model artifacts as a DAG | Artifact with `requires: string[]` | +| Track completion state | Sets for `completed`, `in_progress`, `failed` | +| Calculate build order | Kahn's algorithm (topological sort) | +| Find ready artifacts | Check if all dependencies are in `completed` set | + +**Key Data Structures:** + +``` +Artifact { + id: string + generates: string // e.g., "proposal.md" or "specs/*.md" + description: string + instruction: string // path to template + requires: string[] // artifact IDs this depends on +} + +ArtifactState { + completed: Set + inProgress: Set + failed: Set +} + +ArtifactGraph { + artifacts: Map +} +``` + +**Key Methods:** +- `fromYaml(path)` - Load artifact definitions from YAML +- `getNextArtifacts(state)` - Find artifacts ready to create +- `getBuildOrder()` - Topological sort of all artifacts +- `isComplete(state)` - Check if all artifacts done + +--- + +### 2. ChangeManager + +Multi-change orchestration layer. **CLI is fully deterministic** - no "active change" tracking. + +| Responsibility | Approach | +|----------------|----------| +| CRUD changes | Create dirs under `openspec/changes//` | +| Template fallback | Schema-specific → Shared (2 levels max) | + +**Key Paths:** + +``` +.openspec/schemas/ → Schema definitions (artifact graphs) +.openspec/templates/ → Shared instruction templates +openspec/changes// → Change instances with artifacts +``` + +**Key Methods:** +- `isInitialized()` - Check for `.openspec/` existence +- `listChanges()` - List all changes in `openspec/changes/` +- `createChange(name, description)` - Create new change directory +- `getChangePath(name)` - Get path to a change directory +- `getSchemaPath(schemaName?)` - Find schema with fallback +- `getTemplatePath(artifactId, schemaName?)` - Find template (schema → shared) + +**Note:** No `getActiveChange()`, `setActiveChange()`, or `resolveChange()` - the agent infers which change from conversation context and passes it explicitly to CLI commands. + +--- + +### 3. InstructionLoader + +State detection and instruction enrichment. + +| Responsibility | Approach | +|----------------|----------| +| Detect artifact completion | Scan filesystem, support glob patterns | +| Build dynamic context | Gather dependency status, change info | +| Enrich templates | Inject context into base templates | +| Generate status reports | Formatted markdown with progress | + +**Key Class - ChangeState:** + +``` +ChangeState { + changeName: string + changeDir: string + graph: ArtifactGraph + state: ArtifactState + + // Methods + getNextSteps(): string[] + getStatus(artifactId): ArtifactStatus + isComplete(): boolean +} +``` + +**Key Functions:** +- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point +- `getChangeStatus(projectRoot, changeName?)` - Formatted status report +- `resolveTemplatePath(artifactId, schemaName?)` - 2-level fallback + +--- + +### 4. CLI + +User interface layer. **All commands are deterministic** - require explicit `--change` parameter. + +| Command | Function | +|---------|----------| +| `status --change ` | Show change progress | +| `next --change ` | Show artifacts ready to create | +| `instructions --change ` | Get enriched instructions for artifact | +| `list` | List all changes | +| `new ` | Create change | +| `init` | Initialize structure | +| `templates --change ` | Show resolved template paths | + +**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly. + +--- + +### 5. Claude Commands + +Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language. + +| Command | Purpose | +|---------|---------| +| `/status` | Show change progress | +| `/next` | Show what's ready to create | +| `/run [artifact]` | Execute a specific step (power users) | +| `/list` | List all changes | +| `/new ` | Create a new change | +| `/init` | Initialize structure | + +**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent: +1. Infers change from conversation (confirms if uncertain) +2. Infers artifact from request +3. Calls CLI with explicit `--change` parameter +4. Creates artifact following instructions + +This works for ANY artifact in ANY schema - no new slash commands needed when schemas change. + +**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture. + +--- + +## Component Dependency Graph + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PRESENTATION LAYER │ +│ ┌──────────────┐ ┌────────────────────┐ │ +│ │ CLI │ ←─shell exec───────│ Claude Commands │ │ +│ └──────┬───────┘ └────────────────────┘ │ +└─────────┼───────────────────────────────────────────────────┘ + │ imports + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ORCHESTRATION LAYER │ +│ ┌────────────────────┐ ┌──────────────────────────┐ │ +│ │ InstructionLoader │───────▶│ ChangeManager │ │ +│ │ │ uses │ │ │ +│ └─────────┬──────────┘ └──────────────────────────┘ │ +└────────────┼────────────────────────────────────────────────┘ + │ uses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CORE LAYER │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ ArtifactGraph │ │ +│ │ │ │ +│ │ Artifact ←────── ArtifactState │ │ +│ │ (data) (runtime state) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ reads from + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ PERSISTENCE LAYER │ +│ ┌──────────────────┐ ┌────────────────────────────────┐ │ +│ │ YAML Config │ │ Filesystem Artifacts │ │ +│ │ - config.yaml │ │ - proposal.md, design.md │ │ +│ │ - schema.yaml │ │ - specs/*.md, tasks.md │ │ +│ └──────────────────┘ └────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Design Patterns + +### 1. Filesystem as Database + +No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete. + +``` +// State detection is just file existence checking +if (exists(artifactPath)) { + completed.add(artifactId) +} +``` + +### 2. Deterministic CLI, Inferring Agent + +**CLI layer:** Always deterministic - requires explicit `--change` parameter. + +``` +openspec status --change add-auth # explicit, works +openspec status # error: "No change specified" +``` + +**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`. + +This separation means: +- CLI is pure, testable, no state to corrupt +- Agent handles all "smartness" +- No config.yaml tracking of "active change" + +### 3. Two-Level Template Fallback + +``` +schema-specific/templates/proposal.md + ↓ (not found) +.openspec/templates/proposal.md (shared) + ↓ (not found) +Error (no silent fallback to avoid confusion) +``` + +### 4. Glob Pattern Support + +`specs/*.md` allows multiple files to satisfy a single artifact: + +``` +if (artifact.generates.includes("*")) { + const parentDir = changeDir / patternParts[0] + if (exists(parentDir) && hasFiles(parentDir)) { + completed.add(artifactId) + } +} +``` + +### 5. Stateless State Detection + +Every command re-scans the filesystem. No cached state to corrupt. + +--- + +## Artifact Pipeline (Default Schema) + +The default `spec-driven` schema: + +``` +┌──────────┐ +│ proposal │ (no dependencies) +└────┬─────┘ + │ + ▼ +┌──────────┐ +│ specs │ (requires: proposal) +└────┬─────┘ + │ + ├──────────────┐ + ▼ ▼ +┌──────────┐ ┌──────────┐ +│ design │ │ │ +│ │◄──┤ proposal │ +└────┬─────┘ └──────────┘ + │ (requires: proposal, specs) + ▼ +┌──────────┐ +│ tasks │ (requires: design) +└──────────┘ +``` + +Other schemas (TDD, prototype-first) would have different graphs. + +--- + +## Implementation Order + +Structured as **vertical slices** - each slice is independently testable. + +--- + +### Slice 1: "What's Ready?" (Core Query) + +**Combines:** Types + Graph + State Detection + +``` +Input: schema YAML path + change directory +Output: { + completed: ['proposal'], + ready: ['specs'], + blocked: ['design', 'tasks'], + buildOrder: ['proposal', 'specs', 'design', 'tasks'] +} +``` + +**Testable behaviors:** +- Parse schema YAML → returns correct artifact graph +- Compute build order (topological sort) → correct ordering +- Empty directory → only root artifacts (no dependencies) are ready +- Directory with `proposal.md` → `specs` becomes ready +- Directory with `specs/foo.md` → glob pattern detected as complete +- All artifacts present → `isComplete()` returns true + +--- + +### Slice 2: "Multi-Change Management" + +**Delivers:** CRUD for changes, path resolution + +**Testable behaviors:** +- `createChange('add-auth')` → creates directory + README +- `listChanges()` → returns directory names +- `getChangePath('add-auth')` → returns correct path +- Missing change → clear error message + +--- + +### Slice 3: "Get Instructions" (Enrichment) + +**Delivers:** Template resolution + context injection + +**Testable behaviors:** +- Template fallback: schema-specific → shared → error +- Context injection: completed deps show ✓, missing show ✗ +- Output path shown correctly based on change directory + +--- + +### Slice 4: "CLI + Integration" + +**Delivers:** Full command interface + +**Testable behaviors:** +- Each command produces expected output +- Commands compose correctly (status → next → instructions flow) +- Error handling for missing changes, invalid artifacts, etc. + +--- + +## Directory Structure + +``` +.openspec/ +├── schemas/ # Schema definitions +│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks +│ ├── spec-driven-v2.yaml # Version 2 +│ ├── tdd.yaml # TDD: tests → implementation → docs +│ └── tdd/ +│ └── templates/ # TDD-specific template overrides +│ └── tests.md +│ +└── templates/ # Shared instruction templates + ├── proposal.md + ├── design.md + ├── specs.md + └── tasks.md + +openspec/ +└── changes/ # Change instances + ├── add-auth/ + │ ├── README.md + │ ├── proposal.md # Created artifacts + │ ├── design.md + │ └── specs/ + │ └── *.md + │ + └── refactor-db/ + └── ... + +.claude/ +├── settings.local.json # Permissions +└── commands/ # Slash commands + └── *.md +``` + +--- + +## Schema YAML Format + +```yaml +# .openspec/schemas/spec-driven.yaml +name: spec-driven +version: 1 +description: Specification-driven development + +artifacts: + - id: proposal + generates: "proposal.md" + description: "Create project proposal document" + template: "proposal.md" # resolves via 2-level fallback + requires: [] + + - id: specs + generates: "specs/*.md" # glob pattern + description: "Create technical specification documents" + template: "specs.md" + requires: + - proposal + + - id: design + generates: "design.md" + description: "Create design document" + template: "design.md" + requires: + - proposal + - specs + + - id: tasks + generates: "tasks.md" + description: "Create tasks breakdown document" + template: "tasks.md" + requires: + - design +``` + +--- + +## Summary + +| Layer | Component | Responsibility | +|-------|-----------|----------------| +| Core | ArtifactGraph | Pure dependency logic (no I/O) | +| Core | ChangeManager | Multi-change orchestration | +| Core | InstructionLoader | State detection + enrichment | +| Presentation | CLI | Thin command wrapper | +| Integration | Claude Commands | AI assistant glue | + +**Key Principles:** +- **Filesystem IS the database** - stateless, version-control friendly +- **Dependencies are enablers** - show what's possible, don't force order +- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context +- **2-level template inheritance** - shared + override, no deeper +- **Schemas are versioned** - support variations by philosophy, version, language From 3ceef2db725b8f70661c70b2c6a5d9fec9055ff0 Mon Sep 17 00:00:00 2001 From: Eunsong-Park <111448985+smileeunsong@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:44:34 +0900 Subject: [PATCH 12/12] fix(archive): allow REMOVED requirements when creating new spec files (#403) (#404) When creating a new spec file, REMOVED requirements are now ignored with a warning instead of causing archive to fail. This enables refactoring scenarios where old fields are removed while documenting a capability for the first time. Fixes #403 --- src/core/archive.ts | 30 ++++++--- test/core/archive.test.ts | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/src/core/archive.ts b/src/core/archive.ts index c97560245..7263bb1c0 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,6 +1,5 @@ import { promises as fs } from 'fs'; import path from 'path'; -import { FileSystemUtils } from '../utils/file-system.js'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { Validator } from './validation/validator.js'; import chalk from 'chalk'; @@ -447,15 +446,26 @@ export class ArchiveCommand { // Load or create base target content let targetContent: string; + let isNewSpec = false; try { targetContent = await fs.readFile(update.target, 'utf-8'); } catch { - // Target spec does not exist; only ADDED operations are permitted - if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) { + // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs + // REMOVED will be ignored with a warning since there's nothing to remove + if (plan.modified.length > 0 || plan.renamed.length > 0) { throw new Error( - `${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.` + `${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.` ); } + // Warn about REMOVED requirements being ignored for new specs + if (plan.removed.length > 0) { + console.log( + chalk.yellow( + `⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).` + ) + ); + } + isNewSpec = true; targetContent = this.buildSpecSkeleton(specName, changeName); } @@ -498,9 +508,15 @@ export class ArchiveCommand { for (const name of plan.removed) { const key = normalizeRequirementName(name); if (!nameToBlock.has(key)) { - throw new Error( - `${specName} REMOVED failed for header "### Requirement: ${name}" - not found` - ); + // For new specs, REMOVED requirements are already warned about and ignored + // For existing specs, missing requirements are an error + if (!isNewSpec) { + throw new Error( + `${specName} REMOVED failed for header "### Requirement: ${name}" - not found` + ); + } + // Skip removal for new specs (already warned above) + continue; } nameToBlock.delete(key); } diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index d950d2194..597dbfb2f 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -127,6 +127,133 @@ Then expected result happens`; expect(updatedContent).toContain('#### Scenario: Basic test'); }); + it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { + const changeName = 'new-spec-with-removed'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'gift-card'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with both ADDED and REMOVED requirements + // This simulates refactoring where old fields are removed and new ones are added + const specContent = `# Gift Card - Changes + +## ADDED Requirements + +### Requirement: Logo and Background Color +The system SHALL support logo and backgroundColor fields for gift cards. + +#### Scenario: Display gift card with logo +- **WHEN** a gift card is displayed +- **THEN** it shows the logo and backgroundColor + +## REMOVED Requirements + +### Requirement: Image Field +### Requirement: Thumbnail Field`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should succeed with warning about REMOVED requirements + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify warning was logged about REMOVED requirements being ignored + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Warning: gift-card - 2 REMOVED requirement(s) ignored for new spec (nothing to remove).') + ); + + // Verify spec was created with only ADDED requirements + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'gift-card', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(updatedContent).toContain('# gift-card Specification'); + expect(updatedContent).toContain('### Requirement: Logo and Background Color'); + expect(updatedContent).toContain('#### Scenario: Display gift card with logo'); + // REMOVED requirements should not be in the final spec + expect(updatedContent).not.toContain('### Requirement: Image Field'); + expect(updatedContent).not.toContain('### Requirement: Thumbnail Field'); + + // Verify change was archived successfully + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBeGreaterThan(0); + expect(archives.some(a => a.includes(changeName))).toBe(true); + }); + + it('should still error on MODIFIED when creating new spec file', async () => { + const changeName = 'new-spec-with-modified'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with MODIFIED requirement (should fail for new spec) + const specContent = `# New Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## MODIFIED Requirements + +### Requirement: Existing Feature +Modified content.`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should abort with error message (not throw, but log and return) + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify error message mentions MODIFIED not allowed for new specs + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('new-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Verify spec was NOT created + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was NOT archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + + it('should still error on RENAMED when creating new spec file', async () => { + const changeName = 'new-spec-with-renamed'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'another-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // Create delta spec with RENAMED requirement (should fail for new spec) + const specContent = `# Another Capability - Changes + +## ADDED Requirements + +### Requirement: New Feature +New feature description. + +## RENAMED Requirements +- FROM: \`### Requirement: Old Name\` +- TO: \`### Requirement: New Name\``; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + // Execute archive - should abort with error message (not throw, but log and return) + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // Verify error message mentions RENAMED not allowed for new specs + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('another-capability: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.') + ); + expect(console.log).toHaveBeenCalledWith('Aborted. No files were changed.'); + + // Verify spec was NOT created + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'another-capability', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + + // Verify change was NOT archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.some(a => a.includes(changeName))).toBe(false); + }); + it('should throw error if change does not exist', async () => { await expect( archiveCommand.execute('non-existent-change', { yes: true })