mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a2704870 | ||
|
|
4fb73b6b7d |
+10
-9
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user