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/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 2dc30d1da..593d13350 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 }} @@ -14,7 +15,7 @@ concurrency: jobs: prepare: - if: github.repository == 'Fission-AI/OpenSpec' + if: github.repository == 'appboypov/OpenSplx' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -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) 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/CHANGELOG.md b/CHANGELOG.md index 88f7608ae..740cb9ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,44 @@ Initial release of OpenSplx fork. ## Upstream History (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 + +- 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 + +- 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/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 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/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/package.json b/package.json index fe301a75c..b556fbf6c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@fission-ai/openspec", + "name": "@appboypov/opensplx", "version": "0.1.0", "description": "AI-native system for spec-driven development", "keywords": [ @@ -9,10 +9,10 @@ "ai", "development" ], - "homepage": "https://github.com/Fission-AI/OpenSpec", + "homepage": "https://github.com/appboypov/OpenSplx", "repository": { "type": "git", - "url": "https://github.com/Fission-AI/OpenSpec" + "url": "https://github.com/appboypov/OpenSplx" }, "license": "MIT", "author": "OpenSpec Contributors", @@ -39,6 +39,7 @@ "!dist/**/*.map" ], "scripts": { + "lint": "eslint src/", "build": "node build.js", "dev": "tsc --watch", "dev:cli": "pnpm build && node bin/openspec.js", @@ -63,7 +64,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/cli/index.ts b/src/cli/index.ts index ba867dbbc..7a3cd6abf 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'; // Import command name detection utility import { commandName } from '../utils/command-name.js'; @@ -203,6 +204,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..3df9f852d --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +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 { confirm } = await import('@inquirer/prompts'); + 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/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/core/archive.ts b/src/core/archive.ts index f7d2e6df9..3fd23c121 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'; @@ -468,15 +467,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); } @@ -519,9 +529,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/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/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/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/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/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 }) 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({}); + }); + }); +}); 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); + }); + }); +});