diff --git a/openspec/changes/add-config-command/proposal.md b/openspec/changes/add-config-command/proposal.md deleted file mode 100644 index 8374ef4a..00000000 --- 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 00000000..8d0703f7 --- /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 00000000..5a8056f3 --- /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 00000000..a856ecf5 --- /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 00000000..572e70e3 --- /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 00000000..fb2b8380 --- /dev/null +++ b/openspec/specs/cli-config/spec.md @@ -0,0 +1,217 @@ +# cli-config Specification + +## Purpose +Provide a user-friendly CLI interface for viewing and modifying global OpenSpec configuration settings without manually editing JSON files. +## Requirements +### Requirement: Command Structure + +The config command SHALL provide subcommands for all configuration operations. + +#### Scenario: Available subcommands + +- **WHEN** user executes `openspec config --help` +- **THEN** display available subcommands: + - `path` - Show config file location + - `list` - Show all current settings + - `get ` - Get a specific value + - `set ` - Set a value + - `unset ` - Remove a key (revert to default) + - `reset` - Reset configuration to defaults + - `edit` - Open config in editor + +### Requirement: Config Path + +The config command SHALL display the config file location. + +#### Scenario: Show config path + +- **WHEN** user executes `openspec config path` +- **THEN** print the absolute path to the config file +- **AND** exit with code 0 + +### Requirement: Config List + +The config command SHALL display all current configuration values. + +#### Scenario: List config in human-readable format + +- **WHEN** user executes `openspec config list` +- **THEN** display all config values in YAML-like format +- **AND** show nested objects with indentation + +#### Scenario: List config as JSON + +- **WHEN** user executes `openspec config list --json` +- **THEN** output the complete config as valid JSON +- **AND** output only JSON (no additional text) + +### Requirement: Config Get + +The config command SHALL retrieve specific configuration values. + +#### Scenario: Get top-level key + +- **WHEN** user executes `openspec config get ` with a valid top-level key +- **THEN** print the raw value only (no labels or formatting) +- **AND** exit with code 0 + +#### Scenario: Get nested key with dot notation + +- **WHEN** user executes `openspec config get featureFlags.someFlag` +- **THEN** traverse the nested structure using dot notation +- **AND** print the value at that path + +#### Scenario: Get non-existent key + +- **WHEN** user executes `openspec config get ` with a key that does not exist +- **THEN** print nothing (empty output) +- **AND** exit with code 1 + +#### Scenario: Get object value + +- **WHEN** user executes `openspec config get ` where the value is an object +- **THEN** print the object as JSON + +### Requirement: Config Set + +The config command SHALL set configuration values with automatic type coercion. + +#### Scenario: Set string value + +- **WHEN** user executes `openspec config set ` +- **AND** value does not match boolean or number patterns +- **THEN** store value as a string +- **AND** display confirmation message + +#### Scenario: Set boolean value + +- **WHEN** user executes `openspec config set true` or `openspec config set false` +- **THEN** store value as boolean (not string) +- **AND** display confirmation message + +#### Scenario: Set numeric value + +- **WHEN** user executes `openspec config set ` +- **AND** value is a valid number (integer or float) +- **THEN** store value as number (not string) + +#### Scenario: Force string with --string flag + +- **WHEN** user executes `openspec config set --string` +- **THEN** store value as string regardless of content +- **AND** this allows storing literal "true" or "123" as strings + +#### Scenario: Set nested key + +- **WHEN** user executes `openspec config set featureFlags.newFlag true` +- **THEN** create intermediate objects if they don't exist +- **AND** set the value at the nested path + +### Requirement: Config Unset + +The config command SHALL remove configuration overrides. + +#### Scenario: Unset existing key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key exists in the config +- **THEN** remove the key from the config file +- **AND** the value reverts to its default +- **AND** display confirmation message + +#### Scenario: Unset non-existent key + +- **WHEN** user executes `openspec config unset ` +- **AND** the key does not exist in the config +- **THEN** display message indicating key was not set +- **AND** exit with code 0 + +### Requirement: Config Reset + +The config command SHALL reset configuration to defaults. + +#### Scenario: Reset all with confirmation + +- **WHEN** user executes `openspec config reset --all` +- **THEN** prompt for confirmation before proceeding +- **AND** if confirmed, delete the config file or reset to defaults +- **AND** display confirmation message + +#### Scenario: Reset all with -y flag + +- **WHEN** user executes `openspec config reset --all -y` +- **THEN** reset without prompting for confirmation + +#### Scenario: Reset without --all flag + +- **WHEN** user executes `openspec config reset` without `--all` +- **THEN** display error indicating `--all` is required +- **AND** exit with code 1 + +### Requirement: Config Edit + +The config command SHALL open the config file in the user's editor. + +#### Scenario: Open editor successfully + +- **WHEN** user executes `openspec config edit` +- **AND** `$EDITOR` or `$VISUAL` environment variable is set +- **THEN** open the config file in that editor +- **AND** create the config file with defaults if it doesn't exist +- **AND** wait for the editor to close before returning + +#### Scenario: No editor configured + +- **WHEN** user executes `openspec config edit` +- **AND** neither `$EDITOR` nor `$VISUAL` is set +- **THEN** display error message suggesting to set `$EDITOR` +- **AND** exit with code 1 + +### Requirement: Key Naming Convention + +The config command SHALL use camelCase keys matching the JSON structure. + +#### Scenario: Keys match JSON structure + +- **WHEN** accessing configuration keys via CLI +- **THEN** use camelCase matching the actual JSON property names +- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`) + +### Requirement: Schema Validation + +The config command SHALL validate configuration writes against the config schema using zod, while rejecting unknown keys for `config set` unless explicitly overridden. + +#### Scenario: Unknown key rejected by default + +- **WHEN** user executes `openspec config set someFutureKey 123` +- **THEN** display a descriptive error message indicating the key is invalid +- **AND** do not modify the config file +- **AND** exit with code 1 + +#### Scenario: Unknown key accepted with override + +- **WHEN** user executes `openspec config set someFutureKey 123 --allow-unknown` +- **THEN** the value is saved successfully +- **AND** exit with code 0 + +#### Scenario: Invalid feature flag value rejected + +- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean` +- **THEN** display a descriptive error message +- **AND** do not modify the config file +- **AND** exit with code 1 + +### Requirement: Reserved Scope Flag + +The config command SHALL reserve the `--scope` flag for future extensibility. + +#### Scenario: Scope flag defaults to global + +- **WHEN** user executes any config command without `--scope` +- **THEN** operate on global configuration (default behavior) + +#### Scenario: Project scope not yet implemented + +- **WHEN** user executes `openspec config --scope project ` +- **THEN** display error message: "Project-local config is not yet implemented" +- **AND** exit with code 1 diff --git a/src/cli/index.ts b/src/cli/index.ts index 9ae2c239..e8cb2f53 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; +import { registerConfigCommand } from '../commands/config.js'; const program = new Command(); const require = createRequire(import.meta.url); @@ -200,6 +201,7 @@ program }); registerSpecCommand(program); +registerConfigCommand(program); // Top-level validate command program diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 00000000..2ee7216c --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +import { confirm } from '@inquirer/prompts'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import { + getGlobalConfigPath, + getGlobalConfig, + saveGlobalConfig, + GlobalConfig, +} from '../core/global-config.js'; +import { + getNestedValue, + setNestedValue, + deleteNestedValue, + coerceValue, + formatValueYaml, + validateConfigKeyPath, + validateConfig, + DEFAULT_CONFIG, +} from '../core/config-schema.js'; + +/** + * Register the config command and all its subcommands. + * + * @param program - The Commander program instance + */ +export function registerConfigCommand(program: Command): void { + const configCmd = program + .command('config') + .description('View and modify global OpenSpec configuration') + .option('--scope ', 'Config scope (only "global" supported currently)') + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.scope && opts.scope !== 'global') { + console.error('Error: Project-local config is not yet implemented'); + process.exit(1); + } + }); + + // config path + configCmd + .command('path') + .description('Show config file location') + .action(() => { + console.log(getGlobalConfigPath()); + }); + + // config list + configCmd + .command('list') + .description('Show all current settings') + .option('--json', 'Output as JSON') + .action((options: { json?: boolean }) => { + const config = getGlobalConfig(); + + if (options.json) { + console.log(JSON.stringify(config, null, 2)); + } else { + console.log(formatValueYaml(config)); + } + }); + + // config get + configCmd + .command('get ') + .description('Get a specific value (raw, scriptable)') + .action((key: string) => { + const config = getGlobalConfig(); + const value = getNestedValue(config as Record, key); + + if (value === undefined) { + process.exitCode = 1; + return; + } + + if (typeof value === 'object' && value !== null) { + console.log(JSON.stringify(value)); + } else { + console.log(String(value)); + } + }); + + // config set + configCmd + .command('set ') + .description('Set a value (auto-coerce types)') + .option('--string', 'Force value to be stored as string') + .option('--allow-unknown', 'Allow setting unknown keys') + .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { + const allowUnknown = Boolean(options.allowUnknown); + const keyValidation = validateConfigKeyPath(key); + if (!keyValidation.valid && !allowUnknown) { + const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; + console.error(`Error: Invalid configuration key "${key}".${reason}`); + console.error('Use "openspec config list" to see available keys.'); + console.error('Pass --allow-unknown to bypass this check.'); + process.exitCode = 1; + return; + } + + const config = getGlobalConfig() as Record; + const coercedValue = coerceValue(value, options.string || false); + + // Create a copy to validate before saving + const newConfig = JSON.parse(JSON.stringify(config)); + setNestedValue(newConfig, key, coercedValue); + + // Validate the new config + const validation = validateConfig(newConfig); + if (!validation.success) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + return; + } + + // Apply changes and save + setNestedValue(config, key, coercedValue); + saveGlobalConfig(config as GlobalConfig); + + const displayValue = + typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + console.log(`Set ${key} = ${displayValue}`); + }); + + // config unset + configCmd + .command('unset ') + .description('Remove a key (revert to default)') + .action((key: string) => { + const config = getGlobalConfig() as Record; + const existed = deleteNestedValue(config, key); + + if (existed) { + saveGlobalConfig(config as GlobalConfig); + console.log(`Unset ${key} (reverted to default)`); + } else { + console.log(`Key "${key}" was not set`); + } + }); + + // config reset + configCmd + .command('reset') + .description('Reset configuration to defaults') + .option('--all', 'Reset all configuration (required)') + .option('-y, --yes', 'Skip confirmation prompts') + .action(async (options: { all?: boolean; yes?: boolean }) => { + if (!options.all) { + console.error('Error: --all flag is required for reset'); + console.error('Usage: openspec config reset --all [-y]'); + process.exitCode = 1; + return; + } + + if (!options.yes) { + const confirmed = await confirm({ + message: 'Reset all configuration to defaults?', + default: false, + }); + + if (!confirmed) { + console.log('Reset cancelled.'); + return; + } + } + + saveGlobalConfig({ ...DEFAULT_CONFIG }); + console.log('Configuration reset to defaults'); + }); + + // config edit + configCmd + .command('edit') + .description('Open config in $EDITOR') + .action(async () => { + const editor = process.env.EDITOR || process.env.VISUAL; + + if (!editor) { + console.error('Error: No editor configured'); + console.error('Set the EDITOR or VISUAL environment variable to your preferred editor'); + console.error('Example: export EDITOR=vim'); + process.exitCode = 1; + return; + } + + const configPath = getGlobalConfigPath(); + + // Ensure config file exists with defaults + if (!fs.existsSync(configPath)) { + saveGlobalConfig({ ...DEFAULT_CONFIG }); + } + + // Spawn editor and wait for it to close + // Avoid shell parsing to correctly handle paths with spaces in both + // the editor path and config path + const child = spawn(editor, [configPath], { + stdio: 'inherit', + shell: false, + }); + + await new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Editor exited with code ${code}`)); + } + }); + child.on('error', reject); + }); + + try { + const rawConfig = fs.readFileSync(configPath, 'utf-8'); + const parsedConfig = JSON.parse(rawConfig); + const validation = validateConfig(parsedConfig); + + if (!validation.success) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.error(`Error: Config file not found at ${configPath}`); + } else if (error instanceof SyntaxError) { + console.error(`Error: Invalid JSON in ${configPath}`); + console.error(error.message); + } else { + console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`); + } + process.exitCode = 1; + } + }); +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index fc4b67ea..f0898ec9 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 00000000..78d27b48 --- /dev/null +++ b/src/core/config-schema.ts @@ -0,0 +1,230 @@ +import { z } from 'zod'; + +/** + * Zod schema for global OpenSpec configuration. + * Uses passthrough() to preserve unknown fields for forward compatibility. + */ +export const GlobalConfigSchema = z + .object({ + featureFlags: z + .record(z.string(), z.boolean()) + .optional() + .default({}), + }) + .passthrough(); + +export type GlobalConfigType = z.infer; + +/** + * Default configuration values. + */ +export const DEFAULT_CONFIG: GlobalConfigType = { + featureFlags: {}, +}; + +const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG)); + +/** + * Validate a config key path for CLI set operations. + * Unknown top-level keys are rejected unless explicitly allowed by the caller. + */ +export function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } { + const rawKeys = path.split('.'); + + if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) { + return { valid: false, reason: 'Key path must not be empty' }; + } + + const rootKey = rawKeys[0]; + if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) { + return { valid: false, reason: `Unknown top-level key "${rootKey}"` }; + } + + if (rootKey === 'featureFlags') { + if (rawKeys.length > 2) { + return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' }; + } + return { valid: true }; + } + + if (rawKeys.length > 1) { + return { valid: false, reason: `"${rootKey}" does not support nested keys` }; + } + + return { valid: true }; +} + +/** + * Get a nested value from an object using dot notation. + * + * @param obj - The object to access + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @returns The value at the path, or undefined if not found + */ +export function getNestedValue(obj: Record, path: string): unknown { + const keys = path.split('.'); + let current: unknown = obj; + + for (const key of keys) { + if (current === null || current === undefined) { + return undefined; + } + if (typeof current !== 'object') { + return undefined; + } + current = (current as Record)[key]; + } + + return current; +} + +/** + * Set a nested value in an object using dot notation. + * Creates intermediate objects as needed. + * + * @param obj - The object to modify (mutated in place) + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @param value - The value to set + */ +export function setNestedValue(obj: Record, path: string, value: unknown): void { + const keys = path.split('.'); + let current: Record = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + + const lastKey = keys[keys.length - 1]; + current[lastKey] = value; +} + +/** + * Delete a nested value from an object using dot notation. + * + * @param obj - The object to modify (mutated in place) + * @param path - Dot-separated path (e.g., "featureFlags.someFlag") + * @returns true if the key existed and was deleted, false otherwise + */ +export function deleteNestedValue(obj: Record, path: string): boolean { + const keys = path.split('.'); + let current: Record = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') { + return false; + } + current = current[key] as Record; + } + + const lastKey = keys[keys.length - 1]; + if (lastKey in current) { + delete current[lastKey]; + return true; + } + return false; +} + +/** + * Coerce a string value to its appropriate type. + * - "true" / "false" -> boolean + * - Numeric strings -> number + * - Everything else -> string + * + * @param value - The string value to coerce + * @param forceString - If true, always return the value as a string + * @returns The coerced value + */ +export function coerceValue(value: string, forceString: boolean = false): string | number | boolean { + if (forceString) { + return value; + } + + // Boolean coercion + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + + // Number coercion - must be a valid finite number + const num = Number(value); + if (!isNaN(num) && isFinite(num) && value.trim() !== '') { + return num; + } + + return value; +} + +/** + * Format a value for YAML-like display. + * + * @param value - The value to format + * @param indent - Current indentation level + * @returns Formatted string + */ +export function formatValueYaml(value: unknown, indent: number = 0): string { + const indentStr = ' '.repeat(indent); + + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]'; + } + return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\n'); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return '{}'; + } + return entries + .map(([key, val]) => { + const formattedVal = formatValueYaml(val, indent + 1); + if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) { + return `${indentStr}${key}:\n${formattedVal}`; + } + return `${indentStr}${key}: ${formattedVal}`; + }) + .join('\n'); + } + + return String(value); +} + +/** + * Validate a configuration object against the schema. + * + * @param config - The configuration to validate + * @returns Validation result with success status and optional error message + */ +export function validateConfig(config: unknown): { success: boolean; error?: string } { + try { + GlobalConfigSchema.parse(config); + return { success: true }; + } catch (error) { + if (error instanceof z.ZodError) { + const zodError = error as z.ZodError; + const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`); + return { success: false, error: messages.join('; ') }; + } + return { success: false, error: 'Unknown validation error' }; + } +} diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts new file mode 100644 index 00000000..e6880c92 --- /dev/null +++ b/test/commands/config.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('config command integration', () => { + // These tests use real file system operations with XDG_CONFIG_HOME override + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // Create unique temp directory for each test + tempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + + // Save original env and set XDG_CONFIG_HOME + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = tempDir; + + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore original env + process.env = originalEnv; + + // Clean up temp directory + fs.rmSync(tempDir, { recursive: true, force: true }); + + // Restore spies + consoleErrorSpy.mockRestore(); + + // Reset module cache to pick up new XDG_CONFIG_HOME + vi.resetModules(); + }); + + it('should use XDG_CONFIG_HOME for config path', async () => { + const { getGlobalConfigPath } = await import('../../src/core/global-config.js'); + const configPath = getGlobalConfigPath(); + expect(configPath).toBe(path.join(tempDir, 'openspec', 'config.json')); + }); + + it('should save and load config correctly', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + + saveGlobalConfig({ featureFlags: { test: true } }); + const config = getGlobalConfig(); + expect(config.featureFlags).toEqual({ test: true }); + }); + + it('should return defaults when config file does not exist', async () => { + const { getGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); + + const configPath = getGlobalConfigPath(); + // Make sure config doesn't exist + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + + const config = getGlobalConfig(); + expect(config.featureFlags).toEqual({}); + }); + + it('should preserve unknown fields', async () => { + const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js'); + + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ + featureFlags: {}, + customField: 'preserved', + })); + + const config = getGlobalConfig(); + expect((config as Record).customField).toBe('preserved'); + }); + + it('should handle invalid JSON gracefully', async () => { + const { getGlobalConfig, getGlobalConfigDir } = await import('../../src/core/global-config.js'); + + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), '{ invalid json }'); + + const config = getGlobalConfig(); + // Should return defaults + expect(config.featureFlags).toEqual({}); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON')); + }); +}); + +describe('config command shell completion registry', () => { + it('should have config command in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + expect(configCmd).toBeDefined(); + expect(configCmd?.description).toBe('View and modify global OpenSpec configuration'); + }); + + it('should have all config subcommands in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const subcommandNames = configCmd?.subcommands?.map((s) => s.name) ?? []; + + expect(subcommandNames).toContain('path'); + expect(subcommandNames).toContain('list'); + expect(subcommandNames).toContain('get'); + expect(subcommandNames).toContain('set'); + expect(subcommandNames).toContain('unset'); + expect(subcommandNames).toContain('reset'); + expect(subcommandNames).toContain('edit'); + }); + + it('should have --json flag on list subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const listCmd = configCmd?.subcommands?.find((s) => s.name === 'list'); + const flagNames = listCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('json'); + }); + + it('should have --string flag on set subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const setCmd = configCmd?.subcommands?.find((s) => s.name === 'set'); + const flagNames = setCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('string'); + expect(flagNames).toContain('allow-unknown'); + }); + + it('should have --all and -y flags on reset subcommand', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const resetCmd = configCmd?.subcommands?.find((s) => s.name === 'reset'); + const flagNames = resetCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('all'); + expect(flagNames).toContain('yes'); + }); + + it('should have --scope flag on config command', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); + const flagNames = configCmd?.flags?.map((f) => f.name) ?? []; + + expect(flagNames).toContain('scope'); + }); +}); + +describe('config key validation', () => { + it('rejects unknown top-level keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('unknownKey').valid).toBe(false); + }); + + it('allows feature flag keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('featureFlags.someFlag').valid).toBe(true); + }); + + it('rejects deeply nested feature flag keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('featureFlags.someFlag.extra').valid).toBe(false); + }); +}); diff --git a/test/core/config-schema.test.ts b/test/core/config-schema.test.ts new file mode 100644 index 00000000..eeff81cc --- /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({}); + }); + }); +});