diff --git a/openspec/changes/add-global-config-dir/design.md b/openspec/changes/archive/2025-12-20-add-global-config-dir/design.md similarity index 100% rename from openspec/changes/add-global-config-dir/design.md rename to openspec/changes/archive/2025-12-20-add-global-config-dir/design.md diff --git a/openspec/changes/add-global-config-dir/proposal.md b/openspec/changes/archive/2025-12-20-add-global-config-dir/proposal.md similarity index 100% rename from openspec/changes/add-global-config-dir/proposal.md rename to openspec/changes/archive/2025-12-20-add-global-config-dir/proposal.md diff --git a/openspec/changes/add-global-config-dir/specs/global-config/spec.md b/openspec/changes/archive/2025-12-20-add-global-config-dir/specs/global-config/spec.md similarity index 100% rename from openspec/changes/add-global-config-dir/specs/global-config/spec.md rename to openspec/changes/archive/2025-12-20-add-global-config-dir/specs/global-config/spec.md diff --git a/openspec/changes/add-global-config-dir/tasks.md b/openspec/changes/archive/2025-12-20-add-global-config-dir/tasks.md similarity index 51% rename from openspec/changes/add-global-config-dir/tasks.md rename to openspec/changes/archive/2025-12-20-add-global-config-dir/tasks.md index 5fecd731..240d5e88 100644 --- a/openspec/changes/add-global-config-dir/tasks.md +++ b/openspec/changes/archive/2025-12-20-add-global-config-dir/tasks.md @@ -1,25 +1,26 @@ ## 1. Core Implementation -- [ ] 1.1 Create `src/core/global-config.ts` with path resolution +- [x] 1.1 Create `src/core/global-config.ts` with path resolution - Implement `getGlobalConfigDir()` following XDG spec - Support `$XDG_CONFIG_HOME` environment variable override - Platform-specific fallbacks (Unix: `~/.config/`, Windows: `%APPDATA%`) -- [ ] 1.2 Define TypeScript interfaces for config shape +- [x] 1.2 Define TypeScript interfaces for config shape - `GlobalConfig` interface with optional fields - Start minimal: just `featureFlags?: Record` -- [ ] 1.3 Implement config loading with defaults +- [x] 1.3 Implement config loading with defaults - `getGlobalConfig()` - reads config.json if exists, merges with defaults - No directory/file creation on read (lazy initialization) -- [ ] 1.4 Implement config saving +- [x] 1.4 Implement config saving - `saveGlobalConfig(config)` - writes config.json, creates directory if needed ## 2. Integration -- [ ] 2.1 Export new module from `src/core/index.ts` -- [ ] 2.2 Add constants for config file name and directory name +- [x] 2.1 Export new module from `src/core/index.ts` +- [x] 2.2 Add constants for config file name and directory name ## 3. Testing -- [ ] 3.1 Manual testing of path resolution on current platform -- [ ] 3.2 Test with/without `$XDG_CONFIG_HOME` set -- [ ] 3.3 Test config load when file doesn't exist (should return defaults) +- [x] 3.1 Manual testing of path resolution on current platform +- [x] 3.2 Test with/without `$XDG_CONFIG_HOME` set +- [x] 3.3 Test config load when file doesn't exist (should return defaults) +- [x] 3.4 Unit tests in `test/core/global-config.test.ts` (18 tests) diff --git a/openspec/specs/global-config/spec.md b/openspec/specs/global-config/spec.md new file mode 100644 index 00000000..b8538aad --- /dev/null +++ b/openspec/specs/global-config/spec.md @@ -0,0 +1,81 @@ +# global-config Specification + +## Purpose + +This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules. +## Requirements +### Requirement: Global Config Directory Path + +The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks. + +#### Scenario: Unix/macOS with XDG_CONFIG_HOME set +- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config` +- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec` + +#### Scenario: Unix/macOS without XDG_CONFIG_HOME +- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set +- **AND** the platform is Unix or macOS +- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path) + +#### Scenario: Windows platform +- **WHEN** the platform is Windows +- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming` +- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec` + +### Requirement: Global Config Loading + +The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed. + +#### Scenario: Config file exists and is valid +- **WHEN** `config.json` exists in the global config directory +- **AND** the file contains valid JSON matching the config schema +- **THEN** `getGlobalConfig()` returns the parsed configuration + +#### Scenario: Config file does not exist +- **WHEN** `config.json` does not exist in the global config directory +- **THEN** `getGlobalConfig()` returns the default configuration +- **AND** no directory or file is created + +#### Scenario: Config file is invalid JSON +- **WHEN** `config.json` exists but contains invalid JSON +- **THEN** `getGlobalConfig()` returns the default configuration +- **AND** a warning is logged to stderr + +### Requirement: Global Config Saving + +The system SHALL save global configuration to the config directory, creating the directory if it does not exist. + +#### Scenario: Save config to new directory +- **WHEN** `saveGlobalConfig(config)` is called +- **AND** the global config directory does not exist +- **THEN** the directory is created +- **AND** `config.json` is written with the provided configuration + +#### Scenario: Save config to existing directory +- **WHEN** `saveGlobalConfig(config)` is called +- **AND** the global config directory already exists +- **THEN** `config.json` is written (overwriting if exists) + +### Requirement: Default Configuration + +The system SHALL provide a default configuration that is used when no config file exists. + +#### Scenario: Default config structure +- **WHEN** no config file exists +- **THEN** the default configuration includes an empty `featureFlags` object + +### Requirement: Config Schema Evolution + +The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files. + +#### Scenario: Config file missing new fields +- **WHEN** `config.json` exists with `{ "featureFlags": {} }` +- **AND** the current schema includes a new field `defaultAiTool` +- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: }` +- **AND** the loaded values take precedence over defaults for fields that exist in both + +#### Scenario: Config file has extra unknown fields +- **WHEN** `config.json` contains fields not in the current schema +- **THEN** the unknown fields are preserved in the returned configuration +- **AND** no error or warning is raised + diff --git a/src/core/global-config.ts b/src/core/global-config.ts new file mode 100644 index 00000000..65c6a8d5 --- /dev/null +++ b/src/core/global-config.ts @@ -0,0 +1,102 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Constants +export const GLOBAL_CONFIG_DIR_NAME = 'openspec'; +export const GLOBAL_CONFIG_FILE_NAME = 'config.json'; + +// TypeScript interfaces +export interface GlobalConfig { + featureFlags?: Record; +} + +const DEFAULT_CONFIG: GlobalConfig = { + featureFlags: {} +}; + +/** + * Gets the global configuration directory path following XDG Base Directory Specification. + * + * - Unix/macOS: $XDG_CONFIG_HOME/openspec/ or ~/.config/openspec/ + * - Windows: %APPDATA%/openspec/ + */ +export function getGlobalConfigDir(): string { + const platform = os.platform(); + + if (platform === 'win32') { + // Windows: use %APPDATA% + const appData = process.env.APPDATA; + if (appData) { + return path.join(appData, GLOBAL_CONFIG_DIR_NAME); + } + // Fallback for Windows if APPDATA is not set + return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME); + } + + // Unix/macOS: use XDG_CONFIG_HOME or fallback to ~/.config + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) { + return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME); + } + + return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME); +} + +/** + * Gets the path to the global config file. + */ +export function getGlobalConfigPath(): string { + return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME); +} + +/** + * Loads the global configuration from disk. + * Returns default configuration if file doesn't exist or is invalid. + * Merges loaded config with defaults to ensure new fields are available. + */ +export function getGlobalConfig(): GlobalConfig { + const configPath = getGlobalConfigPath(); + + try { + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + const content = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(content); + + // Merge with defaults (loaded values take precedence) + return { + ...DEFAULT_CONFIG, + ...parsed, + // Deep merge featureFlags + featureFlags: { + ...DEFAULT_CONFIG.featureFlags, + ...(parsed.featureFlags || {}) + } + }; + } catch (error) { + // Log warning for parse errors, but not for missing files + if (error instanceof SyntaxError) { + console.error(`Warning: Invalid JSON in ${configPath}, using defaults`); + } + return { ...DEFAULT_CONFIG }; + } +} + +/** + * Saves the global configuration to disk. + * Creates the config directory if it doesn't exist. + */ +export function saveGlobalConfig(config: GlobalConfig): void { + const configDir = getGlobalConfigDir(); + const configPath = getGlobalConfigPath(); + + // Create directory if it doesn't exist + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} diff --git a/src/core/index.ts b/src/core/index.ts index 0f52581e..22afd94d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,2 +1,10 @@ // Core OpenSpec logic will be implemented here -export {}; \ No newline at end of file +export { + GLOBAL_CONFIG_DIR_NAME, + GLOBAL_CONFIG_FILE_NAME, + type GlobalConfig, + getGlobalConfigDir, + getGlobalConfigPath, + getGlobalConfig, + saveGlobalConfig +} from './global-config.js'; \ No newline at end of file diff --git a/test/core/global-config.test.ts b/test/core/global-config.test.ts new file mode 100644 index 00000000..cdf75f0a --- /dev/null +++ b/test/core/global-config.test.ts @@ -0,0 +1,255 @@ +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'; + +import { + getGlobalConfigDir, + getGlobalConfigPath, + getGlobalConfig, + saveGlobalConfig, + GLOBAL_CONFIG_DIR_NAME, + GLOBAL_CONFIG_FILE_NAME +} from '../../src/core/global-config.js'; + +describe('global-config', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // Create temp directory for tests + tempDir = path.join(os.tmpdir(), `openspec-global-config-test-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + // Save original env + originalEnv = { ...process.env }; + + // Spy on console.error for warning tests + 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 console.error + consoleErrorSpy.mockRestore(); + }); + + describe('constants', () => { + it('should export correct directory name', () => { + expect(GLOBAL_CONFIG_DIR_NAME).toBe('openspec'); + }); + + it('should export correct file name', () => { + expect(GLOBAL_CONFIG_FILE_NAME).toBe('config.json'); + }); + }); + + describe('getGlobalConfigDir', () => { + it('should use XDG_CONFIG_HOME when set', () => { + process.env.XDG_CONFIG_HOME = tempDir; + + const result = getGlobalConfigDir(); + + expect(result).toBe(path.join(tempDir, 'openspec')); + }); + + it('should fall back to ~/.config on Unix/macOS without XDG_CONFIG_HOME', () => { + delete process.env.XDG_CONFIG_HOME; + + const result = getGlobalConfigDir(); + + // On non-Windows, should use ~/.config/openspec + if (os.platform() !== 'win32') { + expect(result).toBe(path.join(os.homedir(), '.config', 'openspec')); + } + }); + + it('should use APPDATA on Windows', () => { + // This test only makes sense conceptually - we can't change os.platform() + // But we can verify the APPDATA logic by checking the code path + if (os.platform() === 'win32') { + const appData = process.env.APPDATA; + if (appData) { + const result = getGlobalConfigDir(); + expect(result).toBe(path.join(appData, 'openspec')); + } + } + }); + }); + + describe('getGlobalConfigPath', () => { + it('should return path to config.json in config directory', () => { + process.env.XDG_CONFIG_HOME = tempDir; + + const result = getGlobalConfigPath(); + + expect(result).toBe(path.join(tempDir, 'openspec', 'config.json')); + }); + }); + + describe('getGlobalConfig', () => { + it('should return defaults when config file does not exist', () => { + process.env.XDG_CONFIG_HOME = tempDir; + + const config = getGlobalConfig(); + + expect(config).toEqual({ featureFlags: {} }); + }); + + it('should not create directory when reading non-existent config', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + + getGlobalConfig(); + + expect(fs.existsSync(configDir)).toBe(false); + }); + + it('should load valid config from file', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: { testFlag: true, anotherFlag: false } + })); + + const config = getGlobalConfig(); + + expect(config.featureFlags).toEqual({ testFlag: true, anotherFlag: false }); + }); + + it('should return defaults for invalid JSON', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{ invalid json }'); + + const config = getGlobalConfig(); + + expect(config).toEqual({ featureFlags: {} }); + }); + + it('should log warning for invalid JSON', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{ invalid json }'); + + getGlobalConfig(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid JSON') + ); + }); + + it('should preserve unknown fields from config file', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: { x: true }, + unknownField: 'preserved', + futureOption: 123 + })); + + const config = getGlobalConfig(); + + expect((config as any).unknownField).toBe('preserved'); + expect((config as any).futureOption).toBe(123); + }); + + it('should merge loaded config with defaults', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + // Config with only some fields + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: { customFlag: true } + })); + + const config = getGlobalConfig(); + + // Should have the custom flag + expect(config.featureFlags?.customFlag).toBe(true); + }); + }); + + describe('saveGlobalConfig', () => { + it('should create directory if it does not exist', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + + saveGlobalConfig({ featureFlags: { test: true } }); + + expect(fs.existsSync(configDir)).toBe(true); + }); + + it('should write config to file', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configPath = path.join(tempDir, 'openspec', 'config.json'); + + saveGlobalConfig({ featureFlags: { myFlag: true } }); + + const content = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed.featureFlags.myFlag).toBe(true); + }); + + it('should overwrite existing config file', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + // Create initial config + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ featureFlags: { old: true } })); + + // Overwrite + saveGlobalConfig({ featureFlags: { new: true } }); + + const content = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(content); + expect(parsed.featureFlags.new).toBe(true); + expect(parsed.featureFlags.old).toBeUndefined(); + }); + + it('should write formatted JSON with trailing newline', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configPath = path.join(tempDir, 'openspec', 'config.json'); + + saveGlobalConfig({ featureFlags: {} }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('\n'); + expect(content.endsWith('\n')).toBe(true); + }); + + it('should round-trip config correctly', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const originalConfig = { + featureFlags: { flag1: true, flag2: false } + }; + + saveGlobalConfig(originalConfig); + const loadedConfig = getGlobalConfig(); + + expect(loadedConfig.featureFlags).toEqual(originalConfig.featureFlags); + }); + }); +});