mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ea32369a |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments
|
||||
@@ -142,9 +142,6 @@ jobs:
|
||||
- name: Type check
|
||||
run: pnpm exec tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Check for build artifacts
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write # Required for npm OIDC trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -28,9 +27,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@fission-ai'
|
||||
always-auth: true
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -45,4 +46,5 @@ jobs:
|
||||
publish: pnpm run release:ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# npm authentication handled via OIDC trusted publishing (no token needed)
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a2757e7: Fix pre-commit hook hang issue in config command by using dynamic import for @inquirer/prompts
|
||||
|
||||
The config command was causing pre-commit hooks to hang indefinitely due to stdin event listeners being registered at module load time. This fix converts the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively.
|
||||
|
||||
Also adds ESLint with a rule to prevent static @inquirer imports, avoiding future regressions.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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'],
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
## 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 <key> # Get a specific value
|
||||
openspec config set <key> <value> # 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
|
||||
@@ -1,89 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,60 +0,0 @@
|
||||
## 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 <key> # Get a specific value (raw, scriptable)
|
||||
openspec config set <key> <value> [--string] # Set a value (auto-coerce types)
|
||||
openspec config unset <key> # 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)
|
||||
@@ -1,213 +0,0 @@
|
||||
# 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 <key>` - Get a specific value
|
||||
- `set <key> <value>` - Set a value
|
||||
- `unset <key>` - 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 <key>` 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 <key>` 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 <key>` 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 <key> <value>`
|
||||
- **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 <key> true` or `openspec config set <key> false`
|
||||
- **THEN** store value as boolean (not string)
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set numeric value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **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 <key> <value> --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 <key>`
|
||||
- **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 <key>`
|
||||
- **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 <subcommand>`
|
||||
- **THEN** display error message: "Project-local config is not yet implemented"
|
||||
- **AND** exit with code 1
|
||||
@@ -1,28 +0,0 @@
|
||||
## 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 <key>` subcommand (raw output)
|
||||
- [x] 2.5 Implement `config set <key> <value>` with `--string` flag
|
||||
- [x] 2.6 Implement `config unset <key>` 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)
|
||||
@@ -1,217 +0,0 @@
|
||||
# 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 <key>` - Get a specific value
|
||||
- `set <key> <value>` - Set a value
|
||||
- `unset <key>` - 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 <key>` 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 <key>` 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 <key>` 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 <key> <value>`
|
||||
- **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 <key> true` or `openspec config set <key> false`
|
||||
- **THEN** store value as boolean (not string)
|
||||
- **AND** display confirmation message
|
||||
|
||||
#### Scenario: Set numeric value
|
||||
|
||||
- **WHEN** user executes `openspec config set <key> <value>`
|
||||
- **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 <key> <value> --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 <key>`
|
||||
- **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 <key>`
|
||||
- **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 <subcommand>`
|
||||
- **THEN** display error message: "Project-local config is not yet implemented"
|
||||
- **AND** exit with code 1
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.1",
|
||||
"version": "0.16.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -38,7 +38,6 @@
|
||||
"!dist/**/*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"build": "node build.js",
|
||||
"dev": "tsc --watch",
|
||||
"dev:cli": "pnpm build && node bin/openspec.js",
|
||||
@@ -63,9 +62,7 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
-762
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -201,7 +200,6 @@ program
|
||||
});
|
||||
|
||||
registerSpecCommand(program);
|
||||
registerConfigCommand(program);
|
||||
|
||||
// Top-level validate command
|
||||
program
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
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 <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 <key>')
|
||||
.description('Get a specific value (raw, scriptable)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig();
|
||||
const value = getNestedValue(config as Record<string, unknown>, 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 <key> <value>')
|
||||
.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<string, unknown>;
|
||||
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 <key>')
|
||||
.description('Remove a key (revert to default)')
|
||||
.action((key: string) => {
|
||||
const config = getGlobalConfig() as Record<string, unknown>;
|
||||
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<void>((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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
||||
import { nearestMatches } from '../utils/match.js';
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ExecuteOptions {
|
||||
strict?: boolean;
|
||||
json?: boolean;
|
||||
noInteractive?: boolean;
|
||||
interactive?: boolean; // Commander sets this to false when --no-interactive is used
|
||||
concurrency?: string;
|
||||
}
|
||||
|
||||
@@ -36,7 +35,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, noInteractive: resolveNoInteractive(options) });
|
||||
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,8 +180,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; noInteractive?: boolean }): Promise<void> {
|
||||
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
|
||||
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const spinner = !opts.json ? ora('Validating...').start() : undefined;
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
|
||||
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
|
||||
|
||||
@@ -288,77 +288,4 @@ 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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
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<typeof GlobalConfigSchema>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>, 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<string, unknown>)[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<string, unknown>, path: string, value: unknown): void {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>, path: string): boolean {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>);
|
||||
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' };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type InteractiveOptions = {
|
||||
type InteractiveOptions = {
|
||||
/**
|
||||
* Explicit "disable prompts" flag passed by internal callers.
|
||||
*/
|
||||
@@ -9,12 +9,7 @@ export type InteractiveOptions = {
|
||||
interactive?: 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 {
|
||||
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value?.noInteractive === true || value?.interactive === false;
|
||||
}
|
||||
@@ -22,8 +17,6 @@ export function resolveNoInteractive(value?: boolean | InteractiveOptions): bool
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
@@ -130,18 +130,4 @@ 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?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
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<string, unknown>, '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<string, unknown> = {};
|
||||
setNestedValue(obj, 'foo', 'bar');
|
||||
expect(obj.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should set a nested value', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, 'a.b.c', 'deep');
|
||||
expect((obj.a as Record<string, unknown>).b).toEqual({ c: 'deep' });
|
||||
});
|
||||
|
||||
it('should create intermediate objects', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
setNestedValue(obj, 'x.y.z', 'value');
|
||||
expect(obj).toEqual({ x: { y: { z: 'value' } } });
|
||||
});
|
||||
|
||||
it('should overwrite existing values', () => {
|
||||
const obj: Record<string, unknown> = { a: 'old' };
|
||||
setNestedValue(obj, 'a', 'new');
|
||||
expect(obj.a).toBe('new');
|
||||
});
|
||||
|
||||
it('should overwrite primitive with object when setting nested path', () => {
|
||||
const obj: Record<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { foo: 'bar' };
|
||||
const result = deleteNestedValue(obj, 'baz');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent nested path', () => {
|
||||
const obj: Record<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown>).newFlag).toBe(true);
|
||||
});
|
||||
|
||||
it('should create featureFlags object when setting nested flag', () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
const value = coerceValue('false');
|
||||
setNestedValue(config, 'featureFlags.experimental', value);
|
||||
|
||||
const result = validateConfig(config);
|
||||
expect(result.success).toBe(true);
|
||||
expect((config.featureFlags as Record<string, unknown>).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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { isInteractive, resolveNoInteractive, InteractiveOptions } from '../../src/utils/interactive.js';
|
||||
|
||||
describe('interactive utilities', () => {
|
||||
let originalOpenSpecInteractive: string | undefined;
|
||||
let originalCI: string | undefined;
|
||||
let originalStdinIsTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
|
||||
originalCI = process.env.CI;
|
||||
originalStdinIsTTY = process.stdin.isTTY;
|
||||
|
||||
// Clear environment for clean testing
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
delete process.env.CI;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
if (originalOpenSpecInteractive !== undefined) {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
|
||||
} else {
|
||||
delete process.env.OPEN_SPEC_INTERACTIVE;
|
||||
}
|
||||
if (originalCI !== undefined) {
|
||||
process.env.CI = originalCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
// Restore stdin.isTTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNoInteractive', () => {
|
||||
it('should return true when noInteractive is true', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when interactive is false (Commander.js style)', () => {
|
||||
// This is how Commander.js handles --no-interactive flag
|
||||
expect(resolveNoInteractive({ interactive: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when noInteractive is false', () => {
|
||||
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is true', () => {
|
||||
expect(resolveNoInteractive({ interactive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty options object', () => {
|
||||
expect(resolveNoInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(resolveNoInteractive(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle boolean value true', () => {
|
||||
expect(resolveNoInteractive(true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle boolean value false', () => {
|
||||
expect(resolveNoInteractive(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize noInteractive over interactive when both set', () => {
|
||||
// noInteractive: true should win
|
||||
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
|
||||
// If noInteractive is false, check interactive
|
||||
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInteractive', () => {
|
||||
it('should return false when noInteractive is true', () => {
|
||||
expect(isInteractive({ noInteractive: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
|
||||
expect(isInteractive({ interactive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
|
||||
process.env.OPEN_SPEC_INTERACTIVE = '0';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set', () => {
|
||||
process.env.CI = 'true';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI env var is set to any value', () => {
|
||||
// CI can be set to any value, not just "true"
|
||||
process.env.CI = '1';
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when stdin is not a TTY', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and no flags disable it', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when stdin is TTY and options are undefined', () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
|
||||
expect(isInteractive(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user