mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
* fix(#2652): layer ~/.gsd/defaults.json over built-ins in SDK loadConfig SDK loadConfig only merged built-in CONFIG_DEFAULTS, so pre-project init queries (e.g. resolveModel in Codex installs) ignored user-level knobs like resolve_model_ids: "omit" and emitted Claude model aliases from MODEL_PROFILES. Port the user-defaults layer from get-shit-done/bin/lib/config.cjs:65 to the TS loader. CJS parity: user defaults only apply when no .planning/config.json exists (buildNewProjectConfig already bakes them in at /gsd:new-project time). Fixes #2652 * fix(#2652): isolate GSD_HOME in test, refresh loadConfig JSDoc (CodeRabbit)
This commit is contained in:
@@ -6,16 +6,37 @@ import { tmpdir } from 'node:os';
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let tmpDir: string;
|
||||
let fakeHome: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevGsdHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
// Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir.
|
||||
fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(fakeHome, { recursive: true });
|
||||
prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
// Also isolate GSD_HOME (loadUserDefaults prefers it over HOME).
|
||||
prevGsdHome = process.env.GSD_HOME;
|
||||
delete process.env.GSD_HOME;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
await rm(fakeHome, { recursive: true, force: true });
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
|
||||
else process.env.GSD_HOME = prevGsdHome;
|
||||
});
|
||||
|
||||
async function writeUserDefaults(defaults: unknown) {
|
||||
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
|
||||
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults));
|
||||
}
|
||||
|
||||
it('returns all defaults when config file is missing', async () => {
|
||||
// No config.json created
|
||||
await rm(join(tmpDir, '.planning', 'config.json'), { force: true });
|
||||
@@ -154,6 +175,69 @@ describe('loadConfig', () => {
|
||||
expect(config.parallelization).toBe(0);
|
||||
});
|
||||
|
||||
// ─── User-level defaults (~/.gsd/defaults.json) ─────────────────────────
|
||||
// Regression: issue #2652 — SDK loadConfig ignored user-level defaults
|
||||
// for pre-project Codex installs, so init.quick still emitted Claude
|
||||
// model aliases from MODEL_PROFILES via resolveModel even when the user
|
||||
// had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json.
|
||||
//
|
||||
// Mirrors CJS behavior in get-shit-done/bin/lib/core.cjs:421 (#1683):
|
||||
// user-level defaults only apply when no project .planning/config.json
|
||||
// exists (pre-project context). Once a project is initialized, its
|
||||
// config.json is authoritative — buildNewProjectConfig baked the user
|
||||
// defaults in at /gsd:new-project time.
|
||||
|
||||
it('pre-project: layers user defaults from ~/.gsd/defaults.json', async () => {
|
||||
await writeUserDefaults({ resolve_model_ids: 'omit' });
|
||||
// No project config.json
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBe('omit');
|
||||
// Built-in defaults still present for keys user did not override
|
||||
expect(config.model_profile).toBe('balanced');
|
||||
expect(config.workflow.plan_check).toBe(true);
|
||||
});
|
||||
|
||||
it('pre-project: deep-merges nested keys from user defaults', async () => {
|
||||
await writeUserDefaults({
|
||||
git: { branching_strategy: 'milestone' },
|
||||
agent_skills: { planner: 'user-skill' },
|
||||
});
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('milestone');
|
||||
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
|
||||
expect(config.agent_skills).toEqual({ planner: 'user-skill' });
|
||||
});
|
||||
|
||||
it('project config is authoritative over user defaults (CJS parity)', async () => {
|
||||
// User defaults set resolve_model_ids: "omit", but project config omits it.
|
||||
// Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists,
|
||||
// ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked
|
||||
// the user defaults in at project creation time.
|
||||
await writeUserDefaults({
|
||||
resolve_model_ids: 'omit',
|
||||
model_profile: 'fast',
|
||||
});
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'quality' }),
|
||||
);
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.model_profile).toBe('quality');
|
||||
// User-defaults not layered when project config present
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores malformed ~/.gsd/defaults.json', async () => {
|
||||
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
|
||||
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json');
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
// Falls back to built-in defaults
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('does not mutate CONFIG_DEFAULTS between calls', async () => {
|
||||
const before = structuredClone(CONFIG_DEFAULTS);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { relPlanningPath } from './workstream-utils.js';
|
||||
|
||||
@@ -120,33 +121,76 @@ export const CONFIG_DEFAULTS: GSDConfig = {
|
||||
|
||||
/**
|
||||
* Load project config from `.planning/config.json`, merging with defaults.
|
||||
* Returns full defaults when file is missing or empty.
|
||||
* When project config is missing or empty, layers user defaults
|
||||
* (`~/.gsd/defaults.json`) over built-in defaults.
|
||||
* Throws on malformed JSON with a helpful error message.
|
||||
*/
|
||||
/**
|
||||
* Read user-level defaults from `~/.gsd/defaults.json` (or `$GSD_HOME/.gsd/`
|
||||
* when set). Returns `{}` when the file is missing, empty, or malformed —
|
||||
* matches CJS behavior in `get-shit-done/bin/lib/core.cjs` (#1683, #2652).
|
||||
*/
|
||||
async function loadUserDefaults(): Promise<Record<string, unknown>> {
|
||||
const home = process.env.GSD_HOME || homedir();
|
||||
const defaultsPath = join(home, '.gsd', 'defaults.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(defaultsPath, 'utf-8');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
|
||||
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
|
||||
const rootConfigPath = join(projectDir, '.planning', 'config.json');
|
||||
|
||||
let raw: string;
|
||||
let projectConfigFound = false;
|
||||
try {
|
||||
raw = await readFile(configPath, 'utf-8');
|
||||
projectConfigFound = true;
|
||||
} catch {
|
||||
// If workstream config missing, fall back to root config
|
||||
if (workstream) {
|
||||
try {
|
||||
raw = await readFile(rootConfigPath, 'utf-8');
|
||||
projectConfigFound = true;
|
||||
} catch {
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
raw = '';
|
||||
}
|
||||
} else {
|
||||
// File missing — normal for new projects
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
raw = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-project context: no .planning/config.json exists. Layer user-level
|
||||
// defaults from ~/.gsd/defaults.json over built-in defaults. Mirrors the
|
||||
// CJS fall-back branch in get-shit-done/bin/lib/core.cjs:421 (#1683) so
|
||||
// SDK-dispatched init queries (e.g. resolveModel in Codex installs, #2652)
|
||||
// honor user-level knobs like `resolve_model_ids: "omit"`.
|
||||
if (!projectConfigFound) {
|
||||
const userDefaults = await loadUserDefaults();
|
||||
return mergeDefaults(userDefaults);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
// Empty project config — treat as no project config (CJS core.cjs
|
||||
// catches JSON.parse on empty and falls through to the pre-project path).
|
||||
const userDefaults = await loadUserDefaults();
|
||||
return mergeDefaults(userDefaults);
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
@@ -161,7 +205,12 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
|
||||
// Three-level deep merge: defaults <- parsed
|
||||
// Project config exists — user-level defaults are ignored (CJS parity).
|
||||
// `buildNewProjectConfig` already baked them into config.json at /gsd:new-project.
|
||||
return mergeDefaults(parsed);
|
||||
}
|
||||
|
||||
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
|
||||
return {
|
||||
...structuredClone(CONFIG_DEFAULTS),
|
||||
...parsed,
|
||||
|
||||
Reference in New Issue
Block a user