Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 61a2704870 docs(spec): add Purpose section to global-config spec
Replace placeholder text with a concise description of what the spec governs, its scope, and high-level objectives.
2025-12-20 20:12:57 +11:00
Tabish Bidiwale 4fb73b6b7d feat(core): implement global config directory with XDG support
Add new global-config module following XDG Base Directory Specification with platform-specific fallbacks (Unix: ~/.config/openspec, Windows: %APPDATA%/openspec). Includes config loading with defaults, config saving with directory creation, and full test coverage. Archive add-global-config-dir change.
2025-12-20 20:05:28 +11:00
8 changed files with 457 additions and 10 deletions
@@ -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<string, boolean>`
- [ ] 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)
+81
View File
@@ -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: <default> }`
- **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
+102
View File
@@ -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<string, boolean>;
}
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');
}
+9 -1
View File
@@ -1,2 +1,10 @@
// Core OpenSpec logic will be implemented here
export {};
export {
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME,
type GlobalConfig,
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalConfig,
saveGlobalConfig
} from './global-config.js';
+255
View File
@@ -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<typeof vi.spyOn>;
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);
});
});
});