dev-harness-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/adapters/amazon-q/README.md +23 -0
- package/adapters/antigravity/README.md +22 -0
- package/adapters/claude-code/README.md +30 -0
- package/adapters/cline/README.md +23 -0
- package/adapters/codex/README.md +31 -0
- package/adapters/copilot/README.md +23 -0
- package/adapters/cursor/README.md +29 -0
- package/adapters/gemini/README.md +23 -0
- package/adapters/generic/README.md +40 -0
- package/adapters/hermes/README.md +31 -0
- package/adapters/hermes/SKILL.md +89 -0
- package/adapters/hermes/scripts/init.mjs +27 -0
- package/adapters/hermes/scripts/phase.mjs +27 -0
- package/adapters/hermes/scripts/validate.mjs +27 -0
- package/adapters/kilo-code/README.md +23 -0
- package/adapters/openclaw/README.md +22 -0
- package/adapters/pi/README.md +22 -0
- package/adapters/roo/README.md +23 -0
- package/adapters/windsurf/README.md +23 -0
- package/cli/commands/checkpoint.mjs +94 -0
- package/cli/commands/config.mjs +268 -0
- package/cli/commands/contract.mjs +155 -0
- package/cli/commands/detect-tool.mjs +112 -0
- package/cli/commands/init.mjs +351 -0
- package/cli/commands/learn.mjs +47 -0
- package/cli/commands/pause.mjs +34 -0
- package/cli/commands/phase.mjs +182 -0
- package/cli/commands/resume.mjs +33 -0
- package/cli/commands/rollback.mjs +261 -0
- package/cli/commands/set-mode.mjs +75 -0
- package/cli/commands/status.mjs +168 -0
- package/cli/commands/validate.mjs +118 -0
- package/cli/commands/worktree.mjs +298 -0
- package/cli/harness-dev.mjs +88 -0
- package/cli/lib/args.mjs +111 -0
- package/cli/lib/command-helpers.mjs +50 -0
- package/cli/lib/config-registry.mjs +329 -0
- package/cli/lib/constants.mjs +30 -0
- package/cli/lib/contract.mjs +306 -0
- package/cli/lib/detect-stack.mjs +235 -0
- package/cli/lib/errors.mjs +71 -0
- package/cli/lib/file-io.mjs +90 -0
- package/cli/lib/gates.mjs +492 -0
- package/cli/lib/git.mjs +144 -0
- package/cli/lib/help.mjs +246 -0
- package/cli/lib/modes.mjs +92 -0
- package/cli/lib/output.mjs +49 -0
- package/cli/lib/paths.mjs +75 -0
- package/cli/lib/phases.mjs +58 -0
- package/cli/lib/platform.mjs +78 -0
- package/cli/lib/progress.mjs +357 -0
- package/cli/lib/ralph-inner.mjs +314 -0
- package/cli/lib/ralph-outer.mjs +249 -0
- package/cli/lib/ralph-output.mjs +178 -0
- package/cli/lib/scaffold.mjs +431 -0
- package/cli/lib/schemas/stacks.json +477 -0
- package/cli/lib/state.mjs +333 -0
- package/cli/lib/templates.mjs +264 -0
- package/cli/lib/tool-registry.mjs +218 -0
- package/cli/lib/validate-schema.mjs +131 -0
- package/cli/lib/vars.mjs +114 -0
- package/package.json +50 -0
- package/schema/harness-config.schema.json +127 -0
- package/templates/AGENTS.md +63 -0
- package/templates/ci/github-actions.yml +78 -0
- package/templates/ci/gitlab-ci.yml +59 -0
- package/templates/docs/agents/evaluator.md +14 -0
- package/templates/docs/agents/generator.md +13 -0
- package/templates/docs/agents/planner.md +13 -0
- package/templates/docs/agents/simplifier.md +13 -0
- package/templates/docs/phases/build.md +41 -0
- package/templates/docs/phases/define.md +51 -0
- package/templates/docs/phases/plan.md +36 -0
- package/templates/docs/phases/review.md +42 -0
- package/templates/docs/phases/ship.md +43 -0
- package/templates/docs/phases/simplify.md +40 -0
- package/templates/docs/phases/verify.md +38 -0
- package/templates/evaluator-rubric.md +28 -0
- package/templates/init.ps1 +97 -0
- package/templates/init.sh +102 -0
- package/templates/sprint-contract.md +31 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state — Harness config & state machine.
|
|
3
|
+
*
|
|
4
|
+
* Reads/writes harness-config.json, manages phase transitions,
|
|
5
|
+
* provides dot-notation access for get/set operations.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { loadConfig, saveConfig, get, set, transitionPhase } from './state.mjs';
|
|
9
|
+
* const cfg = loadConfig('/path/to/project');
|
|
10
|
+
* set('/path/to/project', 'gates.enabled', true);
|
|
11
|
+
* transitionPhase('/path/to/project', 'build');
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import { validateAgainstSchema } from './validate-schema.mjs';
|
|
16
|
+
import { getGitBranch, isGitClean, getLastCommitMessage, hasGitUpstream } from './git.mjs';
|
|
17
|
+
import { CONFIG_SCHEMA_PATH, CONFIG_PATH } from './paths.mjs';
|
|
18
|
+
import { PHASE_ORDER, getPhaseOrder, isValidTransition } from './phases.mjs';
|
|
19
|
+
import { DEFAULT_MAX_RETRIES, COVERAGE_THRESHOLD_DEFAULT } from './constants.mjs';
|
|
20
|
+
|
|
21
|
+
// Re-export phase logic for backward compatibility (callers import from state.mjs).
|
|
22
|
+
export { PHASE_ORDER, getPhaseOrder, isValidTransition };
|
|
23
|
+
|
|
24
|
+
// ── Default config ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical default harness-config.json.
|
|
28
|
+
* @returns {object}
|
|
29
|
+
*/
|
|
30
|
+
export function getDefaultConfig() {
|
|
31
|
+
return {
|
|
32
|
+
version: '1.0',
|
|
33
|
+
stack: null,
|
|
34
|
+
stackMeta: null,
|
|
35
|
+
agentTool: null,
|
|
36
|
+
mode: 'copilot',
|
|
37
|
+
currentPhase: null,
|
|
38
|
+
paused: false,
|
|
39
|
+
features: {
|
|
40
|
+
remaining: 0,
|
|
41
|
+
passing: 0,
|
|
42
|
+
total: 0,
|
|
43
|
+
},
|
|
44
|
+
gates: {
|
|
45
|
+
enabled: false,
|
|
46
|
+
checks: ['all'],
|
|
47
|
+
coverage: {
|
|
48
|
+
enabled: false,
|
|
49
|
+
threshold: COVERAGE_THRESHOLD_DEFAULT,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
git: {
|
|
53
|
+
autoCommit: false,
|
|
54
|
+
autoTag: false,
|
|
55
|
+
resetOnRetry: false,
|
|
56
|
+
branch: null,
|
|
57
|
+
clean: true,
|
|
58
|
+
hasUpstream: false,
|
|
59
|
+
lastCommitMessage: null,
|
|
60
|
+
},
|
|
61
|
+
phases: {
|
|
62
|
+
enabled: ['define', 'plan', 'build', 'verify', 'review', 'ship'],
|
|
63
|
+
},
|
|
64
|
+
agents: {
|
|
65
|
+
tone: {
|
|
66
|
+
planner: 'Analytical and precise. Define clear boundaries.',
|
|
67
|
+
generator: 'Focused and practical. Build what\'s specified, nothing more.',
|
|
68
|
+
evaluator: 'Skeptical and thorough. Accept only compelling evidence.',
|
|
69
|
+
simplifier: 'Relentless about clarity. Delete more than you add.',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
73
|
+
retryCount: 0,
|
|
74
|
+
pipelineIteration: 0,
|
|
75
|
+
gateHistory: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── File I/O ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the path to harness-config.json for a given project directory.
|
|
83
|
+
* @param {string} targetDir
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function getConfigPath(targetDir) {
|
|
87
|
+
return CONFIG_PATH(targetDir);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deep-merge a partial config into defaults.
|
|
92
|
+
* Missing keys get default values; extra keys preserved.
|
|
93
|
+
* @param {object} defaults
|
|
94
|
+
* @param {object} partial
|
|
95
|
+
* @returns {object}
|
|
96
|
+
*/
|
|
97
|
+
function deepMerge(defaults, partial) {
|
|
98
|
+
const result = { ...defaults };
|
|
99
|
+
for (const key of Object.keys(partial)) {
|
|
100
|
+
if (
|
|
101
|
+
defaults[key] && typeof defaults[key] === 'object' && !Array.isArray(defaults[key]) &&
|
|
102
|
+
partial[key] && typeof partial[key] === 'object' && !Array.isArray(partial[key])
|
|
103
|
+
) {
|
|
104
|
+
result[key] = deepMerge(defaults[key], partial[key]);
|
|
105
|
+
} else {
|
|
106
|
+
result[key] = partial[key];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load harness-config.json, merging with defaults.
|
|
114
|
+
* Returns defaults if file doesn't exist or is invalid.
|
|
115
|
+
* @param {string} targetDir
|
|
116
|
+
* @returns {{ config: object, path: string, ok: boolean, error: string|null }}
|
|
117
|
+
*/
|
|
118
|
+
export function loadConfig(targetDir) {
|
|
119
|
+
const cfgPath = getConfigPath(targetDir);
|
|
120
|
+
const defaults = getDefaultConfig();
|
|
121
|
+
|
|
122
|
+
if (!existsSync(cfgPath)) {
|
|
123
|
+
return {
|
|
124
|
+
config: defaults,
|
|
125
|
+
path: cfgPath,
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `Not found: ${cfgPath}. Run: harness-dev init`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const raw = readFileSync(cfgPath, 'utf-8');
|
|
133
|
+
const parsed = JSON.parse(raw);
|
|
134
|
+
const config = deepMerge(defaults, parsed);
|
|
135
|
+
// Schema validation — non-blocking: return errors in result so callers
|
|
136
|
+
// (e.g. status command) can surface them. Library does NOT write to stderr
|
|
137
|
+
// to keep the error contract clean (stderr reserved for real errors).
|
|
138
|
+
const schemaResult = validateAgainstSchema(config, CONFIG_SCHEMA_PATH);
|
|
139
|
+
return { config, path: cfgPath, ok: true, error: null, schemaErrors: schemaResult.ok ? [] : schemaResult.errors };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return {
|
|
142
|
+
config: defaults,
|
|
143
|
+
path: cfgPath,
|
|
144
|
+
ok: false,
|
|
145
|
+
error: `Invalid config: ${err.message}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Save config to harness-config.json.
|
|
152
|
+
* @param {string} targetDir
|
|
153
|
+
* @param {object} cfg
|
|
154
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
155
|
+
*/
|
|
156
|
+
export function saveConfig(targetDir, cfg) {
|
|
157
|
+
try {
|
|
158
|
+
const cfgPath = getConfigPath(targetDir);
|
|
159
|
+
mkdirSync(dirname(cfgPath), { recursive: true });
|
|
160
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
161
|
+
return { ok: true, error: null };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return { ok: false, error: err.message };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Dot-notation access ──────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a dot-notation key against an object.
|
|
171
|
+
* @param {object} obj
|
|
172
|
+
* @param {string} key — e.g. "gates.enabled"
|
|
173
|
+
* @returns {any}
|
|
174
|
+
*/
|
|
175
|
+
function resolveKey(obj, key) {
|
|
176
|
+
const parts = key.split('.');
|
|
177
|
+
let current = obj;
|
|
178
|
+
for (const part of parts) {
|
|
179
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
current = current[part];
|
|
183
|
+
}
|
|
184
|
+
return current;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Set a dot-notation key on an object (mutates in-place).
|
|
189
|
+
* @param {object} obj
|
|
190
|
+
* @param {string} key
|
|
191
|
+
* @param {any} value
|
|
192
|
+
*/
|
|
193
|
+
function setKey(obj, key, value) {
|
|
194
|
+
const parts = key.split('.');
|
|
195
|
+
let current = obj;
|
|
196
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
197
|
+
if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
|
|
198
|
+
current[parts[i]] = {};
|
|
199
|
+
}
|
|
200
|
+
current = current[parts[i]];
|
|
201
|
+
}
|
|
202
|
+
current[parts[parts.length - 1]] = value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get a config value by dot-notation key.
|
|
207
|
+
* Loads from disk on each call (always fresh).
|
|
208
|
+
* @param {string} targetDir
|
|
209
|
+
* @param {string} key — dot-notation key
|
|
210
|
+
* @returns {{ value: any, ok: boolean, error: string|null }}
|
|
211
|
+
*/
|
|
212
|
+
export function get(targetDir, key) {
|
|
213
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
214
|
+
if (key) {
|
|
215
|
+
const value = resolveKey(config, key);
|
|
216
|
+
return { value: value !== undefined ? value : null, ok, error };
|
|
217
|
+
}
|
|
218
|
+
return { value: config, ok, error };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Set a config value by dot-notation key and save to disk.
|
|
223
|
+
* @param {string} targetDir
|
|
224
|
+
* @param {string} key — dot-notation key
|
|
225
|
+
* @param {any} value
|
|
226
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
227
|
+
*/
|
|
228
|
+
export function set(targetDir, key, value) {
|
|
229
|
+
const { config } = loadConfig(targetDir);
|
|
230
|
+
setKey(config, key, value);
|
|
231
|
+
return saveConfig(targetDir, config);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Phase transitions ────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Record a gate result in gateHistory.
|
|
238
|
+
* @param {object} config
|
|
239
|
+
* @param {string} phase
|
|
240
|
+
* @param {string} result — "pass" | "fail"
|
|
241
|
+
*/
|
|
242
|
+
function recordGate(config, phase, result) {
|
|
243
|
+
if (!config.gateHistory) {config.gateHistory = [];}
|
|
244
|
+
config.gateHistory.push({
|
|
245
|
+
phase,
|
|
246
|
+
result,
|
|
247
|
+
timestamp: new Date().toISOString(),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Transition to the next phase.
|
|
253
|
+
*
|
|
254
|
+
* Steps:
|
|
255
|
+
* 1. Validate transition
|
|
256
|
+
* 2. Record old phase gate to history
|
|
257
|
+
* 3. Update currentPhase
|
|
258
|
+
* 4. Update git.branch, git.clean, git.lastCommitMessage
|
|
259
|
+
* 5. Save config
|
|
260
|
+
*
|
|
261
|
+
* @param {string} targetDir
|
|
262
|
+
* @param {string} toPhase
|
|
263
|
+
* @returns {{ ok: boolean, error: string|null, config: object|null }}
|
|
264
|
+
*/
|
|
265
|
+
export function transitionPhase(targetDir, toPhase) {
|
|
266
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
267
|
+
if (!ok) {
|
|
268
|
+
return { ok: false, error: error || 'Cannot load config', config: null };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const enabled = config.phases?.enabled;
|
|
272
|
+
if (!isValidTransition(config.currentPhase, toPhase, enabled)) {
|
|
273
|
+
const order = getPhaseOrder(enabled).join(' → ');
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `Invalid transition: "${config.currentPhase || 'start'}" → "${toPhase}". Valid order: ${order}`,
|
|
277
|
+
config: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Record old phase gate if leaving a phase (skip for same-phase re-run)
|
|
282
|
+
if (config.currentPhase && config.currentPhase !== toPhase) {
|
|
283
|
+
recordGate(config, config.currentPhase, 'pass');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Retry tracking: increment on same-phase re-run, reset on new phase
|
|
287
|
+
const isNewPhase = config.currentPhase !== toPhase;
|
|
288
|
+
if (config.retryCount === undefined) {config.retryCount = 0;}
|
|
289
|
+
if (isNewPhase) {
|
|
290
|
+
config.retryCount = 0;
|
|
291
|
+
} else {
|
|
292
|
+
config.retryCount = (config.retryCount || 0) + 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Update phase
|
|
296
|
+
config.currentPhase = toPhase;
|
|
297
|
+
|
|
298
|
+
// Update git metadata
|
|
299
|
+
config.git = config.git || {};
|
|
300
|
+
config.git.branch = getGitBranch(targetDir);
|
|
301
|
+
config.git.clean = isGitClean(targetDir);
|
|
302
|
+
config.git.hasUpstream = hasGitUpstream(targetDir);
|
|
303
|
+
config.git.lastCommitMessage = getLastCommitMessage(targetDir);
|
|
304
|
+
|
|
305
|
+
// Clear pause on transition
|
|
306
|
+
config.paused = false;
|
|
307
|
+
|
|
308
|
+
const saveResult = saveConfig(targetDir, config);
|
|
309
|
+
if (!saveResult.ok) {
|
|
310
|
+
return { ok: false, error: saveResult.error, config: null };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { ok: true, error: null, config };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Validate config against the JSON schema.
|
|
318
|
+
* Returns list of missing required fields.
|
|
319
|
+
* @param {object} cfg
|
|
320
|
+
* @returns {string[]}
|
|
321
|
+
*/
|
|
322
|
+
export function validateConfig(cfg) {
|
|
323
|
+
const required = ['version', 'mode', 'currentPhase', 'gates', 'git', 'phases', 'maxRetries'];
|
|
324
|
+
// Fields where `null` is a valid value (must not report as missing)
|
|
325
|
+
const nullable = new Set(['currentPhase']);
|
|
326
|
+
const missing = [];
|
|
327
|
+
for (const field of required) {
|
|
328
|
+
if (cfg[field] === undefined || (!nullable.has(field) && cfg[field] === null)) {
|
|
329
|
+
missing.push(field);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return missing;
|
|
333
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* templates — Stack-aware template engine CLI.
|
|
4
|
+
*
|
|
5
|
+
* Processes templates/ files with {{VAR}} substitution using
|
|
6
|
+
* stack-specific variables from stacks.json.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node cli/lib/templates.mjs --stack python --target /tmp/out
|
|
10
|
+
* node cli/lib/templates.mjs --stack node --target /tmp/out --override version=0.1.0
|
|
11
|
+
* node cli/lib/templates.mjs --stack go --target /tmp/out --json
|
|
12
|
+
*
|
|
13
|
+
* Flags:
|
|
14
|
+
* --stack <name> Required. One of: python, node, go, rust, c, cpp, vhdl, verilog
|
|
15
|
+
* --target <dir> Required. Output directory (created if missing)
|
|
16
|
+
* --override k=v Repeatable. Extra template variables
|
|
17
|
+
* --json Machine-parseable JSON output
|
|
18
|
+
* --help Show this message
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { resolve, join, basename, dirname } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { getStackVars, listStacks } from './vars.mjs';
|
|
25
|
+
import { TEMPLATES_DIR, PROJECT_ROOT } from './paths.mjs';
|
|
26
|
+
import { EXECUTABLE_MODE } from './constants.mjs';
|
|
27
|
+
|
|
28
|
+
const PACKAGE_PATH = resolve(PROJECT_ROOT, 'package.json');
|
|
29
|
+
|
|
30
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export function loadPackageVersion() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(PACKAGE_PATH, 'utf-8');
|
|
35
|
+
return JSON.parse(raw).version || '0.1.0';
|
|
36
|
+
} catch {
|
|
37
|
+
return '0.1.0';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseOverrides(args) {
|
|
42
|
+
const overrides = {};
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
const arg = args[i];
|
|
45
|
+
if (arg.startsWith('--override=')) {
|
|
46
|
+
// --override=k=v (equals between flag & value)
|
|
47
|
+
const val = arg.slice('--override='.length);
|
|
48
|
+
const eqIdx = val.indexOf('=');
|
|
49
|
+
if (eqIdx !== -1) {
|
|
50
|
+
overrides[val.slice(0, eqIdx)] = val.slice(eqIdx + 1);
|
|
51
|
+
}
|
|
52
|
+
} else if (arg === '--override' && i + 1 < args.length) {
|
|
53
|
+
// --override key=value or --override key value
|
|
54
|
+
const key = args[i + 1];
|
|
55
|
+
const eqIdx = key.indexOf('=');
|
|
56
|
+
if (eqIdx !== -1) {
|
|
57
|
+
// --override key=value (one arg with =)
|
|
58
|
+
overrides[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
|
|
59
|
+
i++;
|
|
60
|
+
} else if (i + 2 < args.length) {
|
|
61
|
+
// --override key value (two separate args, no =)
|
|
62
|
+
overrides[key] = args[i + 2];
|
|
63
|
+
i += 2;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return overrides;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Substitute {{VAR}} placeholders in text with actual values.
|
|
72
|
+
* Unknown variables are left as-is (optional variables in templates).
|
|
73
|
+
* @param {string} text
|
|
74
|
+
* @param {Record<string, string>} vars
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function substitute(text, vars) {
|
|
78
|
+
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
79
|
+
return vars[key] !== undefined ? vars[key] : match;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discover template files in the templates directory.
|
|
85
|
+
* Returns sorted list of absolute paths, excluding dotfiles.
|
|
86
|
+
* @returns {string[]}
|
|
87
|
+
*/
|
|
88
|
+
export function discoverTemplates() {
|
|
89
|
+
try {
|
|
90
|
+
const result = [];
|
|
91
|
+
const walk = (dir) => {
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
const fullPath = join(dir, e.name);
|
|
100
|
+
if (e.isDirectory() && !e.name.startsWith('.')) {
|
|
101
|
+
walk(fullPath);
|
|
102
|
+
} else if (e.isFile() && !e.name.startsWith('.')) {
|
|
103
|
+
result.push(fullPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
walk(TEMPLATES_DIR);
|
|
108
|
+
return result.sort();
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run the template engine.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} opts
|
|
118
|
+
* @param {string} opts.stack — stack name
|
|
119
|
+
* @param {string} opts.target — output directory
|
|
120
|
+
* @param {object} [opts.overrides] — extra template variables
|
|
121
|
+
* @param {boolean} [opts.json] — JSON output mode
|
|
122
|
+
* @returns {{ files: string[], errors: string[] }}
|
|
123
|
+
*/
|
|
124
|
+
export function generateTemplates(opts) {
|
|
125
|
+
const { stack, target, overrides = {} } = opts;
|
|
126
|
+
|
|
127
|
+
// Load stack variables
|
|
128
|
+
const stackVars = getStackVars(stack, {
|
|
129
|
+
harnessVersion: loadPackageVersion(),
|
|
130
|
+
maxRetries: '3',
|
|
131
|
+
...overrides,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Discover templates
|
|
135
|
+
const templatePaths = discoverTemplates();
|
|
136
|
+
if (templatePaths.length === 0) {
|
|
137
|
+
return { files: [], errors: ['No templates found in ' + TEMPLATES_DIR] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Ensure target directory exists
|
|
141
|
+
mkdirSync(target, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const created = [];
|
|
144
|
+
const errors = [];
|
|
145
|
+
|
|
146
|
+
for (const tmplPath of templatePaths) {
|
|
147
|
+
const relativePath = tmplPath.startsWith(TEMPLATES_DIR + '/')
|
|
148
|
+
? tmplPath.slice(TEMPLATES_DIR.length + 1)
|
|
149
|
+
: basename(tmplPath);
|
|
150
|
+
const outPath = join(target, relativePath);
|
|
151
|
+
const outDir = dirname(outPath);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (outDir !== target) {
|
|
155
|
+
mkdirSync(outDir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const raw = readFileSync(tmplPath, 'utf-8');
|
|
158
|
+
const rendered = substitute(raw, stackVars);
|
|
159
|
+
writeFileSync(outPath, rendered, 'utf-8');
|
|
160
|
+
|
|
161
|
+
// Make .sh files executable
|
|
162
|
+
if (outPath.endsWith('.sh')) {
|
|
163
|
+
chmodSync(outPath, EXECUTABLE_MODE);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
created.push(outPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
errors.push(`${tmplPath}: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { files: created, errors };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── CLI entry ────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function main() {
|
|
178
|
+
const args = process.argv.slice(2);
|
|
179
|
+
|
|
180
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
181
|
+
process.stdout.write(`Usage: node cli/lib/templates.mjs --stack <name> --target <dir> [options]
|
|
182
|
+
|
|
183
|
+
Flags:
|
|
184
|
+
--stack <name> Required. Project stack (${listStacks().join(', ')})
|
|
185
|
+
--target <dir> Required. Output directory
|
|
186
|
+
--override k=v Repeatable. Extra variables
|
|
187
|
+
--json Machine-parseable output
|
|
188
|
+
--help Show this help
|
|
189
|
+
`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const json = args.includes('--json');
|
|
194
|
+
const stackIdx = args.indexOf('--stack');
|
|
195
|
+
const targetIdx = args.indexOf('--target');
|
|
196
|
+
|
|
197
|
+
if (stackIdx === -1 || stackIdx + 1 >= args.length) {
|
|
198
|
+
const msg = '--stack is required';
|
|
199
|
+
if (json) {
|
|
200
|
+
process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
|
|
201
|
+
} else {
|
|
202
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
203
|
+
}
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (targetIdx === -1 || targetIdx + 1 >= args.length) {
|
|
208
|
+
const msg = '--target is required';
|
|
209
|
+
if (json) {
|
|
210
|
+
process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
|
|
211
|
+
} else {
|
|
212
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
213
|
+
}
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const stack = args[stackIdx + 1];
|
|
218
|
+
const target = resolve(args[targetIdx + 1]);
|
|
219
|
+
|
|
220
|
+
const valid = listStacks();
|
|
221
|
+
if (!valid.includes(stack)) {
|
|
222
|
+
const msg = `Unknown stack "${stack}". Valid: ${valid.join(', ')}`;
|
|
223
|
+
if (json) {
|
|
224
|
+
process.stderr.write(JSON.stringify({ command: 'generate-templates', status: 'error', message: msg }) + '\n');
|
|
225
|
+
} else {
|
|
226
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
227
|
+
}
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const overrides = parseOverrides(args);
|
|
232
|
+
const result = generateTemplates({ stack, target, overrides, json });
|
|
233
|
+
|
|
234
|
+
if (json) {
|
|
235
|
+
const successMsg = result.errors.length > 0
|
|
236
|
+
? `Generated ${result.files.length} file(s) with ${result.errors.length} error(s)`
|
|
237
|
+
: `Generated ${result.files.length} file(s) for stack "${stack}"`;
|
|
238
|
+
process.stdout.write(JSON.stringify({
|
|
239
|
+
command: 'generate-templates',
|
|
240
|
+
status: result.errors.length > 0 ? 'partial' : 'ok',
|
|
241
|
+
message: successMsg,
|
|
242
|
+
stack,
|
|
243
|
+
target,
|
|
244
|
+
filesCreated: result.files.length,
|
|
245
|
+
files: result.files,
|
|
246
|
+
errors: result.errors,
|
|
247
|
+
}) + '\n');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Human output
|
|
252
|
+
for (const f of result.files) {
|
|
253
|
+
process.stdout.write(` ✓ ${f}\n`);
|
|
254
|
+
}
|
|
255
|
+
for (const e of result.errors) {
|
|
256
|
+
process.stderr.write(` ✗ ${e}\n`);
|
|
257
|
+
}
|
|
258
|
+
process.stdout.write(`\nCreated ${result.files.length} file(s) for stack "${stack}"\n`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Only run as CLI when called directly (not when imported as module)
|
|
262
|
+
if (process.argv[1] && basename(process.argv[1]) === basename(fileURLToPath(import.meta.url))) {
|
|
263
|
+
main();
|
|
264
|
+
}
|