mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
settings.md was reading and writing .planning/config.json directly while gsd-tools config-get/config-set route to .planning/workstreams/<slug>/config.json when GSD_WORKSTREAM is active, causing silent write-read drift (Closes #2282). - config.cjs: add cmdConfigPath() — emits the planningDir-resolved config path as plain text (always raw, no JSON wrapping) so shell substitution works correctly - gsd-tools.cjs: wire config-path subcommand - settings.md: resolve GSD_CONFIG_PATH via config-path in ensure_and_load_config; replace hardcoded cat .planning/config.json and Write to .planning/config.json with $GSD_CONFIG_PATH throughout - phase.cjs: fix renameDecimalPhases to preserve zero-padded prefix (06.3 → 06.2 not 6.2) — pre-existing test failure on main - tests/config.test.cjs: add config-path command tests (#2282) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
976 lines
38 KiB
JavaScript
976 lines
38 KiB
JavaScript
/**
|
|
* GSD Tools Tests - config.cjs
|
|
*
|
|
* CLI integration tests for config-ensure-section, config-set, and config-get
|
|
* commands exercised through gsd-tools.cjs via execSync.
|
|
*
|
|
* Requirements: TEST-13
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function readConfig(tmpDir) {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
|
|
function writeConfig(tmpDir, obj) {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
|
|
}
|
|
|
|
// ─── config-ensure-section ───────────────────────────────────────────────────
|
|
|
|
describe('config-ensure-section command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('creates config.json with expected structure and types', () => {
|
|
const result = runGsdTools('config-ensure-section', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.created, true);
|
|
|
|
const config = readConfig(tmpDir);
|
|
// Verify structure and types — exact values may vary if ~/.gsd/defaults.json exists
|
|
assert.strictEqual(typeof config.model_profile, 'string');
|
|
assert.strictEqual(typeof config.commit_docs, 'boolean');
|
|
assert.strictEqual(typeof config.parallelization, 'boolean');
|
|
assert.ok(config.git && typeof config.git === 'object', 'git should be an object');
|
|
assert.strictEqual(typeof config.git.branching_strategy, 'string');
|
|
assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow should be an object');
|
|
assert.strictEqual(typeof config.workflow.research, 'boolean');
|
|
assert.strictEqual(typeof config.workflow.plan_check, 'boolean');
|
|
assert.strictEqual(typeof config.workflow.verifier, 'boolean');
|
|
assert.strictEqual(typeof config.workflow.nyquist_validation, 'boolean');
|
|
// These hardcoded defaults are always present (may be overridden by user defaults)
|
|
assert.ok('model_profile' in config, 'model_profile should exist');
|
|
assert.ok('brave_search' in config, 'brave_search should exist');
|
|
assert.ok('search_gitignored' in config, 'search_gitignored should exist');
|
|
});
|
|
|
|
test('is idempotent — returns already_exists on second call', () => {
|
|
const first = runGsdTools('config-ensure-section', tmpDir);
|
|
assert.ok(first.success, `First call failed: ${first.error}`);
|
|
const firstOutput = JSON.parse(first.output);
|
|
assert.strictEqual(firstOutput.created, true);
|
|
|
|
const second = runGsdTools('config-ensure-section', tmpDir);
|
|
assert.ok(second.success, `Second call failed: ${second.error}`);
|
|
const secondOutput = JSON.parse(second.output);
|
|
assert.strictEqual(secondOutput.created, false);
|
|
assert.strictEqual(secondOutput.reason, 'already_exists');
|
|
});
|
|
|
|
test('detects Brave Search from file-based key', () => {
|
|
// runGsdTools sandboxes HOME=tmpDir, so brave_api_key is written there —
|
|
// no real filesystem side effects, cleanup happens via afterEach.
|
|
const gsdDir = path.join(tmpDir, '.gsd');
|
|
fs.mkdirSync(gsdDir, { recursive: true });
|
|
fs.writeFileSync(path.join(gsdDir, 'brave_api_key'), 'test-key', 'utf-8');
|
|
|
|
const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.brave_search, true);
|
|
});
|
|
|
|
test('merges user defaults from defaults.json', () => {
|
|
// runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there —
|
|
// no real filesystem side effects, cleanup happens via afterEach.
|
|
const gsdDir = path.join(tmpDir, '.gsd');
|
|
fs.mkdirSync(gsdDir, { recursive: true });
|
|
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({
|
|
model_profile: 'quality',
|
|
commit_docs: false,
|
|
}), 'utf-8');
|
|
|
|
const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden');
|
|
assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden');
|
|
assert.ok(config.git && typeof config.git === 'object', 'git should be an object');
|
|
assert.strictEqual(typeof config.git.branching_strategy, 'string', 'git.branching_strategy should be a string');
|
|
});
|
|
|
|
test('merges nested workflow keys from defaults.json preserving unset keys', () => {
|
|
// runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there —
|
|
// no real filesystem side effects, cleanup happens via afterEach.
|
|
const gsdDir = path.join(tmpDir, '.gsd');
|
|
fs.mkdirSync(gsdDir, { recursive: true });
|
|
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({
|
|
workflow: { research: false },
|
|
}), 'utf-8');
|
|
|
|
const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.research, false, 'research should be overridden');
|
|
assert.strictEqual(typeof config.workflow.plan_check, 'boolean', 'plan_check should be a boolean');
|
|
assert.strictEqual(typeof config.workflow.verifier, 'boolean', 'verifier should be a boolean');
|
|
});
|
|
});
|
|
|
|
// ─── config-set ──────────────────────────────────────────────────────────────
|
|
|
|
describe('config-set command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Create initial config
|
|
runGsdTools('config-ensure-section', tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('sets a top-level string value', () => {
|
|
const result = runGsdTools('config-set model_profile quality', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.updated, true);
|
|
assert.strictEqual(output.key, 'model_profile');
|
|
assert.strictEqual(output.value, 'quality');
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'quality');
|
|
});
|
|
|
|
test('coerces true to boolean', () => {
|
|
const result = runGsdTools('config-set commit_docs true', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.commit_docs, true);
|
|
assert.strictEqual(typeof config.commit_docs, 'boolean');
|
|
});
|
|
|
|
test('coerces false to boolean', () => {
|
|
const result = runGsdTools('config-set commit_docs false', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.commit_docs, false);
|
|
assert.strictEqual(typeof config.commit_docs, 'boolean');
|
|
});
|
|
|
|
test('coerces numeric strings to numbers', () => {
|
|
const result = runGsdTools('config-set granularity 42', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.granularity, 42);
|
|
assert.strictEqual(typeof config.granularity, 'number');
|
|
});
|
|
|
|
test('preserves plain strings', () => {
|
|
const result = runGsdTools('config-set model_profile hello', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'hello');
|
|
assert.strictEqual(typeof config.model_profile, 'string');
|
|
});
|
|
|
|
test('sets nested values via dot-notation', () => {
|
|
const result = runGsdTools('config-set workflow.research false', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.research, false);
|
|
});
|
|
|
|
test('auto-creates nested objects for dot-notation', () => {
|
|
// Start with empty config
|
|
writeConfig(tmpDir, {});
|
|
|
|
const result = runGsdTools('config-set workflow.research false', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.research, false);
|
|
assert.strictEqual(typeof config.workflow, 'object');
|
|
});
|
|
|
|
test('rejects unknown config keys', () => {
|
|
const result = runGsdTools('config-set workflow.nyquist_validation_enabled false', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Unknown config key'),
|
|
`Expected "Unknown config key" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('sets workflow.text_mode for remote session support', () => {
|
|
writeConfig(tmpDir, {});
|
|
|
|
const result = runGsdTools('config-set workflow.text_mode true', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.text_mode, true);
|
|
});
|
|
|
|
test('sets workflow.use_worktrees to disable worktree isolation', () => {
|
|
writeConfig(tmpDir, {});
|
|
|
|
const result = runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.use_worktrees, false);
|
|
});
|
|
|
|
test('sets git.base_branch for non-main default branches', () => {
|
|
writeConfig(tmpDir, {});
|
|
|
|
const result = runGsdTools('config-set git.base_branch master', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.git.base_branch, 'master');
|
|
});
|
|
|
|
test('sets intel.enabled to opt into the intel subsystem', () => {
|
|
writeConfig(tmpDir, {});
|
|
|
|
const result = runGsdTools('config-set intel.enabled true', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.intel.enabled, true);
|
|
});
|
|
|
|
test('errors when no key path provided', () => {
|
|
const result = runGsdTools('config-set', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
});
|
|
|
|
test('rejects known invalid nyquist alias keys with a suggestion', () => {
|
|
const result = runGsdTools('config-set workflow.nyquist_validation_enabled false', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.match(result.error, /Unknown config key: workflow\.nyquist_validation_enabled/);
|
|
assert.match(result.error, /workflow\.nyquist_validation/);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.nyquist_validation_enabled, undefined);
|
|
assert.strictEqual(config.workflow.nyquist_validation, true);
|
|
});
|
|
});
|
|
|
|
// ─── config-get ──────────────────────────────────────────────────────────────
|
|
|
|
describe('config-get command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Create config with known values — sandbox HOME to avoid global defaults
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('gets a top-level value', () => {
|
|
const result = runGsdTools('config-get model_profile', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, 'balanced');
|
|
});
|
|
|
|
test('gets a nested value via dot-notation', () => {
|
|
const result = runGsdTools('config-get workflow.research', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, true);
|
|
});
|
|
|
|
test('errors for nonexistent key', () => {
|
|
const result = runGsdTools('config-get nonexistent_key', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Key not found'),
|
|
`Expected "Key not found" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('errors for deeply nested nonexistent key', () => {
|
|
const result = runGsdTools('config-get workflow.nonexistent', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Key not found'),
|
|
`Expected "Key not found" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
describe('when config.json does not exist', () => {
|
|
let emptyTmpDir;
|
|
|
|
beforeEach(() => {
|
|
emptyTmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(emptyTmpDir);
|
|
});
|
|
|
|
test('errors when config.json does not exist', () => {
|
|
const result = runGsdTools('config-get model_profile', emptyTmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('No config.json'),
|
|
`Expected "No config.json" in error: ${result.error}`
|
|
);
|
|
});
|
|
});
|
|
|
|
test('gets git.base_branch after it is set', () => {
|
|
runGsdTools('config-set git.base_branch master', tmpDir);
|
|
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, 'master');
|
|
});
|
|
|
|
test('errors for git.base_branch when not explicitly set', () => {
|
|
// Default config from config-ensure-section does not include git.base_branch,
|
|
// so config-get should return "Key not found" — this triggers auto-detect
|
|
// fallback in the workflow (origin/HEAD detection).
|
|
const result = runGsdTools('config-get git.base_branch', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Key not found'),
|
|
`Expected "Key not found" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('errors when no key path provided', () => {
|
|
const result = runGsdTools('config-get', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
});
|
|
});
|
|
|
|
// ─── config-new-project ───────────────────────────────────────────────────────
|
|
|
|
describe('config-new-project command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('creates full config with all expected keys', () => {
|
|
const choices = JSON.stringify({
|
|
mode: 'interactive',
|
|
granularity: 'standard',
|
|
parallelization: true,
|
|
commit_docs: true,
|
|
model_profile: 'balanced',
|
|
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
|
|
});
|
|
const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
|
|
// User choices present
|
|
assert.strictEqual(config.mode, 'interactive');
|
|
assert.strictEqual(config.granularity, 'standard');
|
|
assert.strictEqual(config.parallelization, true);
|
|
assert.strictEqual(config.commit_docs, true);
|
|
assert.strictEqual(config.model_profile, 'balanced');
|
|
|
|
// Defaults materialized — these were silently missing before
|
|
assert.strictEqual(typeof config.search_gitignored, 'boolean');
|
|
assert.strictEqual(typeof config.brave_search, 'boolean');
|
|
|
|
// git section present with all three keys
|
|
assert.ok(config.git && typeof config.git === 'object', 'git section should exist');
|
|
assert.strictEqual(config.git.branching_strategy, 'none');
|
|
assert.strictEqual(config.git.phase_branch_template, 'gsd/phase-{phase}-{slug}');
|
|
assert.strictEqual(config.git.milestone_branch_template, 'gsd/{milestone}-{slug}');
|
|
|
|
// workflow section present with all keys
|
|
assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow section should exist');
|
|
assert.strictEqual(config.workflow.research, true);
|
|
assert.strictEqual(config.workflow.plan_check, true);
|
|
assert.strictEqual(config.workflow.verifier, true);
|
|
assert.strictEqual(config.workflow.nyquist_validation, true);
|
|
assert.strictEqual(config.workflow.auto_advance, false);
|
|
assert.strictEqual(config.workflow.node_repair, true);
|
|
assert.strictEqual(config.workflow.node_repair_budget, 2);
|
|
assert.strictEqual(config.workflow.ui_phase, true);
|
|
assert.strictEqual(config.workflow.ui_safety_gate, true);
|
|
|
|
// hooks section present
|
|
assert.ok(config.hooks && typeof config.hooks === 'object', 'hooks section should exist');
|
|
assert.strictEqual(config.hooks.context_warnings, true);
|
|
});
|
|
|
|
test('user choices override defaults', () => {
|
|
const choices = JSON.stringify({
|
|
mode: 'yolo',
|
|
granularity: 'coarse',
|
|
parallelization: false,
|
|
commit_docs: false,
|
|
model_profile: 'quality',
|
|
workflow: { research: false, plan_check: false, verifier: true, nyquist_validation: false },
|
|
});
|
|
const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.mode, 'yolo');
|
|
assert.strictEqual(config.granularity, 'coarse');
|
|
assert.strictEqual(config.parallelization, false);
|
|
assert.strictEqual(config.commit_docs, false);
|
|
assert.strictEqual(config.model_profile, 'quality');
|
|
assert.strictEqual(config.workflow.research, false);
|
|
assert.strictEqual(config.workflow.plan_check, false);
|
|
assert.strictEqual(config.workflow.verifier, true);
|
|
assert.strictEqual(config.workflow.nyquist_validation, false);
|
|
// Defaults still present for non-chosen keys
|
|
assert.strictEqual(config.git.branching_strategy, 'none');
|
|
assert.strictEqual(typeof config.search_gitignored, 'boolean');
|
|
});
|
|
|
|
test('works with empty choices — all defaults materialized', () => {
|
|
const result = runGsdTools(['config-new-project', '{}'], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'balanced');
|
|
assert.strictEqual(config.commit_docs, true);
|
|
assert.strictEqual(config.parallelization, true);
|
|
assert.strictEqual(config.search_gitignored, false);
|
|
assert.ok(config.git && typeof config.git === 'object');
|
|
assert.strictEqual(config.git.branching_strategy, 'none');
|
|
assert.ok(config.workflow && typeof config.workflow === 'object');
|
|
assert.strictEqual(config.workflow.nyquist_validation, true);
|
|
assert.strictEqual(config.workflow.auto_advance, false);
|
|
assert.strictEqual(config.workflow.node_repair, true);
|
|
assert.strictEqual(config.workflow.node_repair_budget, 2);
|
|
assert.strictEqual(config.workflow.ui_phase, true);
|
|
assert.strictEqual(config.workflow.ui_safety_gate, true);
|
|
assert.ok(config.hooks && typeof config.hooks === 'object');
|
|
assert.strictEqual(config.hooks.context_warnings, true);
|
|
});
|
|
|
|
test('is idempotent — returns already_exists if config exists', () => {
|
|
const choices = JSON.stringify({ mode: 'yolo', granularity: 'fine' });
|
|
|
|
const first = runGsdTools(['config-new-project', choices], tmpDir);
|
|
assert.ok(first.success, `First call failed: ${first.error}`);
|
|
const firstOut = JSON.parse(first.output);
|
|
assert.strictEqual(firstOut.created, true);
|
|
|
|
const second = runGsdTools(['config-new-project', choices], tmpDir);
|
|
assert.ok(second.success, `Second call failed: ${second.error}`);
|
|
const secondOut = JSON.parse(second.output);
|
|
assert.strictEqual(secondOut.created, false);
|
|
assert.strictEqual(secondOut.reason, 'already_exists');
|
|
|
|
// Config unchanged
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.mode, 'yolo');
|
|
assert.strictEqual(config.granularity, 'fine');
|
|
});
|
|
|
|
test('auto_advance in workflow choices is preserved', () => {
|
|
const choices = JSON.stringify({
|
|
mode: 'yolo',
|
|
granularity: 'standard',
|
|
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true, auto_advance: true },
|
|
});
|
|
const result = runGsdTools(['config-new-project', choices], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.auto_advance, true);
|
|
});
|
|
|
|
test('rejects invalid JSON choices', () => {
|
|
const result = runGsdTools(['config-new-project', '{not-json}'], tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(result.error.includes('Invalid JSON'), `Expected "Invalid JSON" in: ${result.error}`);
|
|
});
|
|
|
|
test('output has created:true and path on success', () => {
|
|
const choices = JSON.stringify({ mode: 'interactive', granularity: 'standard' });
|
|
const result = runGsdTools(['config-new-project', choices], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.created, true);
|
|
assert.strictEqual(out.path, '.planning/config.json');
|
|
});
|
|
});
|
|
|
|
// ─── config-set (research_before_questions and discuss_mode) ──────────────────
|
|
|
|
describe('config-set research_before_questions and discuss_mode', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('workflow.research_before_questions is a valid config key', () => {
|
|
const result = runGsdTools('config-set workflow.research_before_questions true', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.research_before_questions, true);
|
|
});
|
|
|
|
test('workflow.discuss_mode is a valid config key', () => {
|
|
const result = runGsdTools('config-set workflow.discuss_mode assumptions', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.discuss_mode, 'assumptions');
|
|
});
|
|
|
|
test('research_before_questions defaults to false in new configs', () => {
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.research_before_questions, false);
|
|
});
|
|
|
|
test('discuss_mode defaults to discuss in new configs', () => {
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.discuss_mode, 'discuss');
|
|
});
|
|
|
|
test('hooks.research_questions is rejected with suggestion', () => {
|
|
const result = runGsdTools('config-set hooks.research_questions true', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Unknown config key'),
|
|
`Expected "Unknown config key" in error: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
result.error.includes('workflow.research_before_questions'),
|
|
`Expected suggestion for workflow.research_before_questions in error: ${result.error}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── config-set (additional coverage) ────────────────────────────────────────
|
|
|
|
describe('config-set unknown key (no suggestion)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('rejects a key that has no suggestion', () => {
|
|
const result = runGsdTools('config-set totally.unknown.key value', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Unknown config key'),
|
|
`Expected "Unknown config key" in error: ${result.error}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── config-get (additional coverage) ────────────────────────────────────────
|
|
|
|
describe('config-get edge cases', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('errors when traversing a dot-path through a non-object value', () => {
|
|
// model_profile is a string — requesting model_profile.something traverses into a non-object
|
|
writeConfig(tmpDir, { model_profile: 'balanced' });
|
|
const result = runGsdTools('config-get model_profile.something', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Key not found'),
|
|
`Expected "Key not found" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('errors when config.json contains malformed JSON', () => {
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(configPath, '{not valid json', 'utf-8');
|
|
const result = runGsdTools('config-get model_profile', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Failed to read config.json'),
|
|
`Expected "Failed to read config.json" in error: ${result.error}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── config-set-model-profile ─────────────────────────────────────────────────
|
|
|
|
describe('config-set-model-profile command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('sets a valid profile and updates config', () => {
|
|
const result = runGsdTools('config-set-model-profile quality', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.updated, true);
|
|
assert.strictEqual(out.profile, 'quality');
|
|
assert.ok(out.agentToModelMap && typeof out.agentToModelMap === 'object');
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'quality');
|
|
});
|
|
|
|
test('reports previous profile in output', () => {
|
|
const result = runGsdTools('config-set-model-profile budget', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.previousProfile, 'balanced'); // default was balanced
|
|
assert.strictEqual(out.profile, 'budget');
|
|
});
|
|
|
|
test('setting the same profile is a no-op on config but still succeeds', () => {
|
|
// Set to quality first, then set to quality again
|
|
runGsdTools('config-set-model-profile quality', tmpDir);
|
|
const result = runGsdTools('config-set-model-profile quality', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const out = JSON.parse(result.output);
|
|
assert.strictEqual(out.profile, 'quality');
|
|
assert.strictEqual(out.previousProfile, 'quality');
|
|
});
|
|
|
|
test('is case-insensitive', () => {
|
|
const result = runGsdTools('config-set-model-profile BALANCED', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.model_profile, 'balanced');
|
|
});
|
|
|
|
test('rejects invalid profile', () => {
|
|
const result = runGsdTools('config-set-model-profile turbo', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Invalid profile'),
|
|
`Expected "Invalid profile" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('errors when no profile provided', () => {
|
|
const result = runGsdTools('config-set-model-profile', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
});
|
|
|
|
describe('when config is missing', () => {
|
|
let emptyDir;
|
|
|
|
beforeEach(() => {
|
|
emptyDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(emptyDir);
|
|
});
|
|
|
|
test('creates config if missing before setting profile', () => {
|
|
const result = runGsdTools('config-set-model-profile budget', emptyDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(emptyDir);
|
|
assert.strictEqual(config.model_profile, 'budget');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── config-set (workflow.skip_discuss) ───────────────────────────────────────
|
|
|
|
describe('config-set workflow.skip_discuss', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('workflow.skip_discuss is a valid config key', () => {
|
|
const result = runGsdTools('config-set workflow.skip_discuss true', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.skip_discuss, true);
|
|
});
|
|
|
|
test('skip_discuss defaults to false in new configs', () => {
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.skip_discuss, false);
|
|
});
|
|
|
|
test('skip_discuss can be toggled back to false', () => {
|
|
runGsdTools('config-set workflow.skip_discuss true', tmpDir);
|
|
const result = runGsdTools('config-set workflow.skip_discuss false', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.workflow.skip_discuss, false);
|
|
});
|
|
|
|
describe('skip_discuss in config-new-project', () => {
|
|
let emptyDir;
|
|
|
|
beforeEach(() => {
|
|
emptyDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(emptyDir);
|
|
});
|
|
|
|
test('skip_discuss is present in config-new-project output', () => {
|
|
const result = runGsdTools(['config-new-project', '{}'], emptyDir, { HOME: emptyDir, USERPROFILE: emptyDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(emptyDir);
|
|
assert.strictEqual(config.workflow.skip_discuss, false, 'skip_discuss should default to false');
|
|
});
|
|
|
|
test('skip_discuss can be set via config-new-project choices', () => {
|
|
const choices = JSON.stringify({
|
|
workflow: { skip_discuss: true },
|
|
});
|
|
const result = runGsdTools(['config-new-project', choices], emptyDir, { HOME: emptyDir, USERPROFILE: emptyDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(emptyDir);
|
|
assert.strictEqual(config.workflow.skip_discuss, true);
|
|
});
|
|
});
|
|
|
|
test('config-get workflow.skip_discuss returns the set value', () => {
|
|
runGsdTools('config-set workflow.skip_discuss true', tmpDir);
|
|
const result = runGsdTools('config-get workflow.skip_discuss', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, true);
|
|
});
|
|
});
|
|
|
|
// ─── config-set/config-get workflow.use_worktrees ────────────────────────────
|
|
|
|
describe('config-set/config-get workflow.use_worktrees', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('config-get workflow.use_worktrees returns false after setting to false', () => {
|
|
runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
|
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, false);
|
|
});
|
|
|
|
test('config-get workflow.use_worktrees errors when not set (default config)', () => {
|
|
// config-ensure-section does NOT include use_worktrees in hardcoded defaults,
|
|
// so config-get should error with "Key not found". This is the expected behavior
|
|
// that workflows rely on: the shell fallback `|| echo "true"` provides the default.
|
|
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Key not found'),
|
|
`Expected "Key not found" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('config-get workflow.use_worktrees returns true after setting to true', () => {
|
|
runGsdTools('config-set workflow.use_worktrees true', tmpDir);
|
|
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, true);
|
|
});
|
|
|
|
test('use_worktrees can be toggled back and forth', () => {
|
|
runGsdTools('config-set workflow.use_worktrees false', tmpDir);
|
|
runGsdTools('config-set workflow.use_worktrees true', tmpDir);
|
|
const result = runGsdTools('config-get workflow.use_worktrees', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, true);
|
|
});
|
|
});
|
|
|
|
// ─── config-set/config-get context ─────────────────────────────────────────
|
|
|
|
describe('config-set/config-get context', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('config set context dev succeeds', () => {
|
|
const result = runGsdTools('config-set context dev', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.context, 'dev');
|
|
});
|
|
|
|
test('config set context research succeeds', () => {
|
|
const result = runGsdTools('config-set context research', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.context, 'research');
|
|
});
|
|
|
|
test('config set context review succeeds', () => {
|
|
const result = runGsdTools('config-set context review', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const config = readConfig(tmpDir);
|
|
assert.strictEqual(config.context, 'review');
|
|
});
|
|
|
|
test('config get context returns the set value', () => {
|
|
runGsdTools('config-set context dev', tmpDir);
|
|
const result = runGsdTools('config-get context', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output, 'dev');
|
|
});
|
|
|
|
test('config set context rejects invalid values', () => {
|
|
const result = runGsdTools('config-set context foobar', tmpDir);
|
|
assert.strictEqual(result.success, false);
|
|
assert.ok(
|
|
result.error.includes('Invalid context value'),
|
|
`Expected "Invalid context value" in error: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('all three context profile files exist', () => {
|
|
const contextsDir = path.join(__dirname, '..', 'get-shit-done', 'contexts');
|
|
assert.ok(fs.existsSync(path.join(contextsDir, 'dev.md')), 'dev.md should exist');
|
|
assert.ok(fs.existsSync(path.join(contextsDir, 'research.md')), 'research.md should exist');
|
|
assert.ok(fs.existsSync(path.join(contextsDir, 'review.md')), 'review.md should exist');
|
|
});
|
|
});
|
|
|
|
// ─── config-path (#2282) ────────────────────────────────────────────────────
|
|
|
|
describe('config-path command (#2282)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
runGsdTools('config-ensure-section', tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns root config path when no workstream is active', () => {
|
|
const result = runGsdTools('config-path', tmpDir);
|
|
assert.ok(result.success, `config-path failed: ${result.error}`);
|
|
assert.ok(result.output.trim().endsWith('.planning/config.json'), `expected root config path, got: ${result.output}`);
|
|
assert.ok(!result.output.includes('workstreams'), 'should not include workstreams in path');
|
|
});
|
|
|
|
test('returns workstream config path when GSD_WORKSTREAM is set', () => {
|
|
const result = runGsdTools('config-path', tmpDir, { GSD_WORKSTREAM: 'my-stream' });
|
|
assert.ok(result.success, `config-path failed: ${result.error}`);
|
|
assert.ok(result.output.trim().includes('workstreams/my-stream/config.json'), `expected workstream config path, got: ${result.output}`);
|
|
});
|
|
|
|
test('config-path and config-get agree on the active path', () => {
|
|
// Write a value via config-set (uses planningDir internally)
|
|
runGsdTools('config-set model_profile quality', tmpDir);
|
|
// config-path should point to a file containing that value
|
|
const pathResult = runGsdTools('config-path', tmpDir);
|
|
const configPath = pathResult.output.trim();
|
|
const configContent = JSON.parse(require('fs').readFileSync(configPath, 'utf-8'));
|
|
assert.strictEqual(configContent.model_profile, 'quality', 'config-path should point to the file config-set wrote');
|
|
});
|
|
});
|