* fix(3571): load configuration manifests after install * test(3571): simplify missing manifest check
254 lines
9.0 KiB
JavaScript
254 lines
9.0 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* GENERATED FILE — DO NOT EDIT.
|
|
*
|
|
* Source: sdk/src/configuration/index.ts
|
|
* Regenerate: cd sdk && npm run gen:configuration
|
|
*
|
|
* Configuration Module — single source of truth for config loading,
|
|
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
|
*/
|
|
|
|
const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');
|
|
const { join } = require('node:path');
|
|
|
|
// ─── Manifest requires ───────────────────────────────────────────────────────
|
|
function loadConfigurationManifest(fileName) {
|
|
const candidates = [
|
|
// Installed runtime layout: get-shit-done/bin/shared/*.manifest.json
|
|
join(__dirname, '..', 'shared', fileName),
|
|
// Source-repo dev layout: sdk/shared/*.manifest.json
|
|
join(__dirname, '..', '..', '..', 'sdk', 'shared', fileName),
|
|
];
|
|
let lastErr = null;
|
|
for (const candidate of candidates) {
|
|
try {
|
|
return require(candidate);
|
|
} catch (err) {
|
|
const isMissingCandidate =
|
|
err && err.code === 'MODULE_NOT_FOUND' && String(err.message || '').includes(candidate);
|
|
if (!isMissingCandidate) throw err;
|
|
lastErr = err;
|
|
}
|
|
}
|
|
throw new Error(
|
|
`${fileName} not found. Tried:\n${candidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${lastErr?.message}`
|
|
);
|
|
}
|
|
|
|
const CONFIG_DEFAULTS = loadConfigurationManifest('config-defaults.manifest.json');
|
|
const SCHEMA_MANIFEST = loadConfigurationManifest('config-schema.manifest.json');
|
|
const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);
|
|
const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);
|
|
const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {
|
|
const pattern = new RegExp(p.source);
|
|
return {
|
|
...p,
|
|
test: (key) => {
|
|
pattern.lastIndex = 0;
|
|
return pattern.test(key);
|
|
},
|
|
};
|
|
});
|
|
|
|
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
|
const DEPTH_TO_GRANULARITY = {
|
|
quick: 'coarse',
|
|
standard: 'standard',
|
|
comprehensive: 'fine',
|
|
};
|
|
|
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
function planningDir(cwd, workstream) {
|
|
if (!workstream)
|
|
return join(cwd, '.planning');
|
|
return join(cwd, '.planning', 'workstreams', workstream);
|
|
}
|
|
|
|
function detectSubRepos(cwd) {
|
|
const results = [];
|
|
try {
|
|
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory())
|
|
continue;
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
continue;
|
|
const gitPath = join(cwd, entry.name, '.git');
|
|
try {
|
|
if (existsSync(gitPath)) {
|
|
results.push(entry.name);
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
return results.sort();
|
|
}
|
|
|
|
function deepMergeConfig(base, overlay) {
|
|
const result = { ...base };
|
|
for (const key of Object.keys(overlay)) {
|
|
const ov = overlay[key];
|
|
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
|
|
const bv = base[key];
|
|
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
|
|
result[key] = deepMergeConfig(bv, ov);
|
|
}
|
|
else {
|
|
result[key] = deepMergeConfig({}, ov);
|
|
}
|
|
}
|
|
else {
|
|
result[key] = ov;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── Exported functions ───────────────────────────────────────────────────────
|
|
function normalizeLegacyKeys(parsed) {
|
|
const result = { ...parsed };
|
|
const normalizations = [];
|
|
// 1. branching_strategy → git.branching_strategy
|
|
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
|
|
const value = result.branching_strategy;
|
|
const git = result.git ?? {};
|
|
if (git.branching_strategy === undefined) {
|
|
result.git = { ...git, branching_strategy: value };
|
|
}
|
|
else {
|
|
// canonical nested wins — just delete the stale top-level
|
|
result.git = { ...git };
|
|
}
|
|
delete result.branching_strategy;
|
|
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
|
|
}
|
|
// 2. top-level sub_repos → planning.sub_repos
|
|
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
|
|
const value = result.sub_repos;
|
|
const planning = result.planning ?? {};
|
|
if (planning.sub_repos === undefined) {
|
|
result.planning = { ...planning, sub_repos: value };
|
|
}
|
|
else {
|
|
// canonical nested wins — just drop the stale top-level
|
|
result.planning = { ...planning };
|
|
}
|
|
delete result.sub_repos;
|
|
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
|
|
}
|
|
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
|
|
if (result.multiRepo === true) {
|
|
delete result.multiRepo;
|
|
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
|
|
}
|
|
// 4. top-level depth → granularity
|
|
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
|
|
const rawDepth = result.depth;
|
|
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
|
|
result.granularity = mapped;
|
|
delete result.depth;
|
|
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
|
|
}
|
|
return { parsed: result, normalizations };
|
|
}
|
|
|
|
function mergeDefaults(parsed) {
|
|
// Start with a deep clone of defaults, then overlay parsed
|
|
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS));
|
|
return deepMergeConfig(defaults, parsed);
|
|
}
|
|
|
|
async function loadConfig(cwd, options) {
|
|
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(configPath, 'utf-8');
|
|
}
|
|
catch {
|
|
// File missing — return defaults
|
|
return mergeDefaults({});
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (trimmed === '') {
|
|
return mergeDefaults({});
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
}
|
|
catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
|
}
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Config at ${configPath} must be a JSON object`);
|
|
}
|
|
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
|
if (options?.onNormalizations && normalizations.length > 0) {
|
|
options.onNormalizations(normalizations);
|
|
}
|
|
return mergeDefaults(normalized);
|
|
}
|
|
|
|
async function migrateOnDisk(cwd, workstream) {
|
|
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(configPath, 'utf-8');
|
|
}
|
|
catch {
|
|
// File missing — nothing to migrate
|
|
return { migrated: false, normalizations: [], wrote: null };
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (trimmed === '') {
|
|
return { migrated: false, normalizations: [], wrote: null };
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
}
|
|
catch {
|
|
// Malformed — can't migrate
|
|
return { migrated: false, normalizations: [], wrote: null };
|
|
}
|
|
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
|
if (normalizations.length === 0) {
|
|
return { migrated: false, normalizations: [], wrote: null };
|
|
}
|
|
// Resolve multiRepo filesystem detection
|
|
const result = { ...normalized };
|
|
for (const norm of normalizations) {
|
|
if (norm.requiresFilesystem) {
|
|
const detected = detectSubRepos(cwd);
|
|
if (detected.length > 0) {
|
|
const planning = result.planning ?? {};
|
|
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
writeFileSync(configPath, JSON.stringify(result, null, 2));
|
|
}
|
|
catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
|
|
}
|
|
return { migrated: true, normalizations, wrote: configPath };
|
|
}
|
|
|
|
module.exports = {
|
|
loadConfig,
|
|
normalizeLegacyKeys,
|
|
mergeDefaults,
|
|
migrateOnDisk,
|
|
CONFIG_DEFAULTS,
|
|
VALID_CONFIG_KEYS,
|
|
RUNTIME_STATE_KEYS,
|
|
DYNAMIC_KEY_PATTERNS,
|
|
};
|