Skip to content

Commit daeaf19

Browse files
authored
Merge pull request #174 from drivecore/feature/hierarchical-config
Feature/hierarchical config
2 parents 231066e + c5b0016 commit daeaf19

File tree

6 files changed

+546
-92
lines changed

6 files changed

+546
-92
lines changed

packages/cli/src/commands/config.ts

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
getConfig,
99
getDefaultConfig,
1010
updateConfig,
11-
clearAllConfig,
11+
getConfigAtLevel,
12+
clearConfigAtLevel,
13+
ConfigLevel,
1214
} from '../settings/config.js';
1315
import { nameToLogIndex } from '../utils/nameToLogIndex.js';
1416

@@ -89,6 +91,46 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
8991
logLevel: nameToLogIndex(argv.logLevel),
9092
});
9193

94+
// Determine which config level to use based on flags
95+
const configLevel =
96+
argv.global || argv.g ? ConfigLevel.GLOBAL : ConfigLevel.PROJECT;
97+
const levelName = configLevel === ConfigLevel.GLOBAL ? 'global' : 'project';
98+
99+
// Check if project level is writable when needed for operations that write to config
100+
if (
101+
configLevel === ConfigLevel.PROJECT &&
102+
(argv.command === 'set' ||
103+
(argv.command === 'clear' && (argv.key || argv.all)))
104+
) {
105+
try {
106+
// Import directly to avoid circular dependency
107+
const { isProjectSettingsDirWritable } = await import(
108+
'../settings/settings.js'
109+
);
110+
if (!isProjectSettingsDirWritable()) {
111+
logger.error(
112+
chalk.red(
113+
'Cannot write to project configuration directory. Check permissions or use --global flag.',
114+
),
115+
);
116+
logger.info(
117+
'You can use the --global (-g) flag to modify global configuration instead.',
118+
);
119+
return;
120+
}
121+
} catch (error: unknown) {
122+
const errorMessage =
123+
error instanceof Error ? error.message : String(error);
124+
logger.error(
125+
chalk.red(
126+
`Error checking project directory permissions: ${errorMessage}`,
127+
),
128+
);
129+
return;
130+
}
131+
}
132+
133+
// Get merged config for display
92134
const config = getConfig();
93135

94136
// Handle 'list' command
@@ -206,10 +248,28 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
206248
}
207249
}
208250

209-
const updatedConfig = updateConfig({ [argv.key]: parsedValue });
210-
logger.info(
211-
`Updated ${argv.key}: ${chalk.green(updatedConfig[argv.key as keyof typeof updatedConfig])}`,
212-
);
251+
try {
252+
// Update config at the specified level
253+
const updatedConfig = updateConfig(
254+
{ [argv.key]: parsedValue },
255+
configLevel,
256+
);
257+
258+
logger.info(
259+
`Updated ${argv.key}: ${chalk.green(updatedConfig[argv.key as keyof typeof updatedConfig])} at ${levelName} level`,
260+
);
261+
} catch (error: unknown) {
262+
const errorMessage =
263+
error instanceof Error ? error.message : String(error);
264+
logger.error(
265+
chalk.red(`Failed to update configuration: ${errorMessage}`),
266+
);
267+
if (configLevel === ConfigLevel.PROJECT) {
268+
logger.info(
269+
'You can use the --global (-g) flag to modify global configuration instead.',
270+
);
271+
}
272+
}
213273
return;
214274
}
215275

@@ -227,11 +287,24 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
227287
return;
228288
}
229289

230-
// Clear all settings
231-
clearAllConfig();
232-
logger.info(
233-
'All configuration settings have been cleared. Default values will be used.',
234-
);
290+
try {
291+
// Clear settings at the specified level
292+
clearConfigAtLevel(configLevel);
293+
logger.info(
294+
`All ${levelName} configuration settings have been cleared.`,
295+
);
296+
} catch (error: unknown) {
297+
const errorMessage =
298+
error instanceof Error ? error.message : String(error);
299+
logger.error(
300+
chalk.red(`Failed to clear configuration: ${errorMessage}`),
301+
);
302+
if (configLevel === ConfigLevel.PROJECT) {
303+
logger.info(
304+
'You can use the --global (-g) flag to modify global configuration instead.',
305+
);
306+
}
307+
}
235308
return;
236309
}
237310

@@ -272,8 +345,32 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
272345
const defaultValue =
273346
defaultConfig[argv.key as keyof typeof defaultConfig];
274347

348+
// Get the effective config after clearing
349+
const updatedConfig = getConfig();
350+
const newValue = updatedConfig[argv.key as keyof typeof updatedConfig];
351+
352+
// Determine where the new value is coming from
353+
const isDefaultAfterClear =
354+
JSON.stringify(newValue) === JSON.stringify(defaultValue);
355+
const afterClearInGlobal =
356+
!isDefaultAfterClear &&
357+
argv.key in getConfigAtLevel(ConfigLevel.GLOBAL);
358+
const afterClearInProject =
359+
!isDefaultAfterClear &&
360+
!afterClearInGlobal &&
361+
argv.key in getConfigAtLevel(ConfigLevel.PROJECT);
362+
363+
let sourceDisplay = '';
364+
if (isDefaultAfterClear) {
365+
sourceDisplay = '(default)';
366+
} else if (afterClearInProject) {
367+
sourceDisplay = '(from project config)';
368+
} else if (afterClearInGlobal) {
369+
sourceDisplay = '(from global config)';
370+
}
371+
275372
logger.info(
276-
`Cleared ${argv.key}, now using default value: ${chalk.green(defaultValue)}`,
373+
`Cleared ${argv.key} at ${levelName} level, now using: ${chalk.green(newValue)} ${sourceDisplay}`,
277374
);
278375
return;
279376
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import { describe, it, expect, beforeEach, vi } from 'vitest';
5+
6+
// Mock modules
7+
vi.mock('fs', () => ({
8+
existsSync: vi.fn(),
9+
readFileSync: vi.fn(),
10+
writeFileSync: vi.fn(),
11+
unlinkSync: vi.fn(),
12+
}));
13+
14+
vi.mock('path', () => ({
15+
join: vi.fn(),
16+
}));
17+
18+
// Mock settings module
19+
vi.mock('./settings.js', () => ({
20+
getSettingsDir: vi.fn().mockReturnValue('/test/home/dir/.mycoder'),
21+
getProjectSettingsDir: vi.fn().mockReturnValue('/test/project/dir/.mycoder'),
22+
isProjectSettingsDirWritable: vi.fn().mockReturnValue(true),
23+
}));
24+
25+
// Import after mocking
26+
import { readConfigFile } from './config.js';
27+
28+
describe('Hierarchical Configuration', () => {
29+
// Mock file paths
30+
const mockGlobalConfigPath = '/test/home/dir/.mycoder/config.json';
31+
const mockProjectConfigPath = '/test/project/dir/.mycoder/config.json';
32+
33+
// Mock config data
34+
const mockGlobalConfig = {
35+
provider: 'openai',
36+
model: 'gpt-4',
37+
};
38+
39+
const mockProjectConfig = {
40+
model: 'claude-3-opus',
41+
};
42+
43+
beforeEach(() => {
44+
vi.resetAllMocks();
45+
46+
// Set environment
47+
process.env.VITEST = 'true';
48+
49+
// Mock path.join
50+
vi.mocked(path.join).mockImplementation((...args) => {
51+
if (args.includes('/test/home/dir/.mycoder')) {
52+
return mockGlobalConfigPath;
53+
}
54+
if (args.includes('/test/project/dir/.mycoder')) {
55+
return mockProjectConfigPath;
56+
}
57+
return args.join('/');
58+
});
59+
60+
// Mock fs.existsSync
61+
vi.mocked(fs.existsSync).mockReturnValue(true);
62+
63+
// Mock fs.readFileSync
64+
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
65+
if (filePath === mockGlobalConfigPath) {
66+
return JSON.stringify(mockGlobalConfig);
67+
}
68+
if (filePath === mockProjectConfigPath) {
69+
return JSON.stringify(mockProjectConfig);
70+
}
71+
return '';
72+
});
73+
});
74+
75+
// Only test the core function that's actually testable
76+
it('should read config files correctly', () => {
77+
const globalConfig = readConfigFile(mockGlobalConfigPath);
78+
expect(globalConfig).toEqual(mockGlobalConfig);
79+
80+
const projectConfig = readConfigFile(mockProjectConfigPath);
81+
expect(projectConfig).toEqual(mockProjectConfig);
82+
});
83+
});

0 commit comments

Comments
 (0)