Skip to content

Commit 5129a8c

Browse files
authored
feat(core): implement global config directory with XDG support (#377)
* 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. * 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.
1 parent 4ff8930 commit 5129a8c

File tree

8 files changed

+457
-10
lines changed

8 files changed

+457
-10
lines changed

openspec/changes/add-global-config-dir/design.md renamed to openspec/changes/archive/2025-12-20-add-global-config-dir/design.md

File renamed without changes.

openspec/changes/add-global-config-dir/proposal.md renamed to openspec/changes/archive/2025-12-20-add-global-config-dir/proposal.md

File renamed without changes.

openspec/changes/add-global-config-dir/specs/global-config/spec.md renamed to openspec/changes/archive/2025-12-20-add-global-config-dir/specs/global-config/spec.md

File renamed without changes.

openspec/changes/add-global-config-dir/tasks.md renamed to openspec/changes/archive/2025-12-20-add-global-config-dir/tasks.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
## 1. Core Implementation
22

3-
- [ ] 1.1 Create `src/core/global-config.ts` with path resolution
3+
- [x] 1.1 Create `src/core/global-config.ts` with path resolution
44
- Implement `getGlobalConfigDir()` following XDG spec
55
- Support `$XDG_CONFIG_HOME` environment variable override
66
- Platform-specific fallbacks (Unix: `~/.config/`, Windows: `%APPDATA%`)
7-
- [ ] 1.2 Define TypeScript interfaces for config shape
7+
- [x] 1.2 Define TypeScript interfaces for config shape
88
- `GlobalConfig` interface with optional fields
99
- Start minimal: just `featureFlags?: Record<string, boolean>`
10-
- [ ] 1.3 Implement config loading with defaults
10+
- [x] 1.3 Implement config loading with defaults
1111
- `getGlobalConfig()` - reads config.json if exists, merges with defaults
1212
- No directory/file creation on read (lazy initialization)
13-
- [ ] 1.4 Implement config saving
13+
- [x] 1.4 Implement config saving
1414
- `saveGlobalConfig(config)` - writes config.json, creates directory if needed
1515

1616
## 2. Integration
1717

18-
- [ ] 2.1 Export new module from `src/core/index.ts`
19-
- [ ] 2.2 Add constants for config file name and directory name
18+
- [x] 2.1 Export new module from `src/core/index.ts`
19+
- [x] 2.2 Add constants for config file name and directory name
2020

2121
## 3. Testing
2222

23-
- [ ] 3.1 Manual testing of path resolution on current platform
24-
- [ ] 3.2 Test with/without `$XDG_CONFIG_HOME` set
25-
- [ ] 3.3 Test config load when file doesn't exist (should return defaults)
23+
- [x] 3.1 Manual testing of path resolution on current platform
24+
- [x] 3.2 Test with/without `$XDG_CONFIG_HOME` set
25+
- [x] 3.3 Test config load when file doesn't exist (should return defaults)
26+
- [x] 3.4 Unit tests in `test/core/global-config.test.ts` (18 tests)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# global-config Specification
2+
3+
## Purpose
4+
5+
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.
6+
## Requirements
7+
### Requirement: Global Config Directory Path
8+
9+
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
10+
11+
#### Scenario: Unix/macOS with XDG_CONFIG_HOME set
12+
- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`
13+
- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`
14+
15+
#### Scenario: Unix/macOS without XDG_CONFIG_HOME
16+
- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set
17+
- **AND** the platform is Unix or macOS
18+
- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)
19+
20+
#### Scenario: Windows platform
21+
- **WHEN** the platform is Windows
22+
- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming`
23+
- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec`
24+
25+
### Requirement: Global Config Loading
26+
27+
The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.
28+
29+
#### Scenario: Config file exists and is valid
30+
- **WHEN** `config.json` exists in the global config directory
31+
- **AND** the file contains valid JSON matching the config schema
32+
- **THEN** `getGlobalConfig()` returns the parsed configuration
33+
34+
#### Scenario: Config file does not exist
35+
- **WHEN** `config.json` does not exist in the global config directory
36+
- **THEN** `getGlobalConfig()` returns the default configuration
37+
- **AND** no directory or file is created
38+
39+
#### Scenario: Config file is invalid JSON
40+
- **WHEN** `config.json` exists but contains invalid JSON
41+
- **THEN** `getGlobalConfig()` returns the default configuration
42+
- **AND** a warning is logged to stderr
43+
44+
### Requirement: Global Config Saving
45+
46+
The system SHALL save global configuration to the config directory, creating the directory if it does not exist.
47+
48+
#### Scenario: Save config to new directory
49+
- **WHEN** `saveGlobalConfig(config)` is called
50+
- **AND** the global config directory does not exist
51+
- **THEN** the directory is created
52+
- **AND** `config.json` is written with the provided configuration
53+
54+
#### Scenario: Save config to existing directory
55+
- **WHEN** `saveGlobalConfig(config)` is called
56+
- **AND** the global config directory already exists
57+
- **THEN** `config.json` is written (overwriting if exists)
58+
59+
### Requirement: Default Configuration
60+
61+
The system SHALL provide a default configuration that is used when no config file exists.
62+
63+
#### Scenario: Default config structure
64+
- **WHEN** no config file exists
65+
- **THEN** the default configuration includes an empty `featureFlags` object
66+
67+
### Requirement: Config Schema Evolution
68+
69+
The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.
70+
71+
#### Scenario: Config file missing new fields
72+
- **WHEN** `config.json` exists with `{ "featureFlags": {} }`
73+
- **AND** the current schema includes a new field `defaultAiTool`
74+
- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`
75+
- **AND** the loaded values take precedence over defaults for fields that exist in both
76+
77+
#### Scenario: Config file has extra unknown fields
78+
- **WHEN** `config.json` contains fields not in the current schema
79+
- **THEN** the unknown fields are preserved in the returned configuration
80+
- **AND** no error or warning is raised
81+

src/core/global-config.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as os from 'node:os';
4+
5+
// Constants
6+
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
7+
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
8+
9+
// TypeScript interfaces
10+
export interface GlobalConfig {
11+
featureFlags?: Record<string, boolean>;
12+
}
13+
14+
const DEFAULT_CONFIG: GlobalConfig = {
15+
featureFlags: {}
16+
};
17+
18+
/**
19+
* Gets the global configuration directory path following XDG Base Directory Specification.
20+
*
21+
* - Unix/macOS: $XDG_CONFIG_HOME/openspec/ or ~/.config/openspec/
22+
* - Windows: %APPDATA%/openspec/
23+
*/
24+
export function getGlobalConfigDir(): string {
25+
const platform = os.platform();
26+
27+
if (platform === 'win32') {
28+
// Windows: use %APPDATA%
29+
const appData = process.env.APPDATA;
30+
if (appData) {
31+
return path.join(appData, GLOBAL_CONFIG_DIR_NAME);
32+
}
33+
// Fallback for Windows if APPDATA is not set
34+
return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);
35+
}
36+
37+
// Unix/macOS: use XDG_CONFIG_HOME or fallback to ~/.config
38+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
39+
if (xdgConfigHome) {
40+
return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);
41+
}
42+
43+
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
44+
}
45+
46+
/**
47+
* Gets the path to the global config file.
48+
*/
49+
export function getGlobalConfigPath(): string {
50+
return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
51+
}
52+
53+
/**
54+
* Loads the global configuration from disk.
55+
* Returns default configuration if file doesn't exist or is invalid.
56+
* Merges loaded config with defaults to ensure new fields are available.
57+
*/
58+
export function getGlobalConfig(): GlobalConfig {
59+
const configPath = getGlobalConfigPath();
60+
61+
try {
62+
if (!fs.existsSync(configPath)) {
63+
return { ...DEFAULT_CONFIG };
64+
}
65+
66+
const content = fs.readFileSync(configPath, 'utf-8');
67+
const parsed = JSON.parse(content);
68+
69+
// Merge with defaults (loaded values take precedence)
70+
return {
71+
...DEFAULT_CONFIG,
72+
...parsed,
73+
// Deep merge featureFlags
74+
featureFlags: {
75+
...DEFAULT_CONFIG.featureFlags,
76+
...(parsed.featureFlags || {})
77+
}
78+
};
79+
} catch (error) {
80+
// Log warning for parse errors, but not for missing files
81+
if (error instanceof SyntaxError) {
82+
console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
83+
}
84+
return { ...DEFAULT_CONFIG };
85+
}
86+
}
87+
88+
/**
89+
* Saves the global configuration to disk.
90+
* Creates the config directory if it doesn't exist.
91+
*/
92+
export function saveGlobalConfig(config: GlobalConfig): void {
93+
const configDir = getGlobalConfigDir();
94+
const configPath = getGlobalConfigPath();
95+
96+
// Create directory if it doesn't exist
97+
if (!fs.existsSync(configDir)) {
98+
fs.mkdirSync(configDir, { recursive: true });
99+
}
100+
101+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
102+
}

src/core/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
11
// Core OpenSpec logic will be implemented here
2-
export {};
2+
export {
3+
GLOBAL_CONFIG_DIR_NAME,
4+
GLOBAL_CONFIG_FILE_NAME,
5+
type GlobalConfig,
6+
getGlobalConfigDir,
7+
getGlobalConfigPath,
8+
getGlobalConfig,
9+
saveGlobalConfig
10+
} from './global-config.js';

0 commit comments

Comments
 (0)