forgedock 1.0.21 → 1.1.1
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/README.md +227 -174
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +386 -0
- package/bin/engine/phases.mjs +148 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +27 -0
- package/bin/engine-cli.mjs +50 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +693 -1393
- package/bin/init-detect.mjs +95 -0
- package/bin/journey.mjs +783 -0
- package/bin/runner.mjs +847 -0
- package/bin/settings-hook.mjs +112 -0
- package/bin/tui.mjs +6 -3
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +11 -5
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +2 -1
- package/commands/issue.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +346 -34
- package/commands/quality-gate.md +168 -80
- package/commands/review-pr-agents.md +69 -45
- package/commands/review-pr.md +54 -6
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/implement.md +16 -4
- package/commands/work-on/build/validate.md +54 -8
- package/commands/work-on/build.md +27 -9
- package/commands/work-on/close.md +131 -3
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on/review.md +24 -0
- package/commands/work-on-monolithic.md +1 -0
- package/commands/work-on.md +271 -11
- package/docs/BRAND.md +6 -2
- package/docs/CONFIG.md +39 -34
- package/docs/site/.vitepress/config.mjs +85 -0
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +42 -13
- package/docs/site/index.md +1 -1
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +13 -3
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/forge.yaml.minimal +44 -0
package/bin/cinema.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/cinema.mjs — Cinematic rendering primitives for ForgeDock's TUI.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency. Pure presentation: gradients, the block-art F mark, and
|
|
5
|
+
* (in Task 2) shimmer / reveal / progress animation primitives.
|
|
6
|
+
*
|
|
7
|
+
* Two independent gates (spec: degradation ladder):
|
|
8
|
+
* colorMode() — 'truecolor' | '256' | 'none' (NO_COLOR, TTY, TERM)
|
|
9
|
+
* motionEnabled() — animation frames on/off (TTY, FORGE_NO_MOTION, CI, --fast)
|
|
10
|
+
*
|
|
11
|
+
* All functions take env/stdout/argv as parameters (with process defaults)
|
|
12
|
+
* so tests never need module-cache busting.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Palette (spec: Chrome & Ember)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const CHROME_STOPS = ["#f7f3ea", "#d9cfba", "#a99a82", "#6e6252"];
|
|
20
|
+
export const EMBER_STOPS = ["#ff4d00", "#ff8c1a", "#ffd166"];
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Gates
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decide the color capability of the terminal.
|
|
28
|
+
* @returns {'truecolor'|'256'|'none'}
|
|
29
|
+
*/
|
|
30
|
+
export function colorMode(env = process.env, stdout = process.stdout) {
|
|
31
|
+
if (stdout.isTTY !== true) return "none";
|
|
32
|
+
if (env.NO_COLOR) return "none";
|
|
33
|
+
if (env.TERM === "dumb") return "none";
|
|
34
|
+
const ct = (env.COLORTERM || "").toLowerCase();
|
|
35
|
+
if (ct.includes("truecolor") || ct.includes("24bit")) return "truecolor";
|
|
36
|
+
if ((env.TERM || "").includes("256")) return "256";
|
|
37
|
+
// Windows Terminal / modern conhost support truecolor but don't set COLORTERM.
|
|
38
|
+
if (process.platform === "win32") return "truecolor";
|
|
39
|
+
return "256";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether animation frames run. Independent of color:
|
|
44
|
+
* NO_COLOR keeps motion (monochrome choreography); non-TTY/CI/--fast kill it.
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function motionEnabled(
|
|
48
|
+
argv = process.argv,
|
|
49
|
+
env = process.env,
|
|
50
|
+
stdout = process.stdout,
|
|
51
|
+
) {
|
|
52
|
+
if (stdout.isTTY !== true) return false;
|
|
53
|
+
if (env.FORGE_NO_MOTION) return false;
|
|
54
|
+
if (env.CI) return false;
|
|
55
|
+
if (argv.includes("--fast")) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Gradient math
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function hexToRgb(hex) {
|
|
64
|
+
const h = hex.replace("#", "");
|
|
65
|
+
return [
|
|
66
|
+
parseInt(h.slice(0, 2), 16),
|
|
67
|
+
parseInt(h.slice(2, 4), 16),
|
|
68
|
+
parseInt(h.slice(4, 6), 16),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sample a multi-stop gradient at position t ∈ [0,1].
|
|
74
|
+
* @returns {[number, number, number]} RGB triple.
|
|
75
|
+
*/
|
|
76
|
+
export function sampleGradient(stops, t) {
|
|
77
|
+
const rgb = stops.map(hexToRgb);
|
|
78
|
+
const clamped = Math.min(Math.max(t, 0), 1);
|
|
79
|
+
const seg = Math.min(Math.floor(clamped * (rgb.length - 1)), rgb.length - 2);
|
|
80
|
+
const localT = clamped * (rgb.length - 1) - seg;
|
|
81
|
+
const [a, b] = [rgb[seg], rgb[seg + 1]];
|
|
82
|
+
const mix = (x, y) => Math.round(x + (y - x) * localT);
|
|
83
|
+
return [mix(a[0], b[0]), mix(a[1], b[1]), mix(a[2], b[2])];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Quantize an RGB triple to the xterm-256 6×6×6 color cube. */
|
|
87
|
+
function to256([r, g, b]) {
|
|
88
|
+
const q = (v) => Math.round((v / 255) * 5);
|
|
89
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Foreground escape for an RGB triple in the given mode ('' for none). */
|
|
93
|
+
function fg(rgb, mode) {
|
|
94
|
+
if (mode === "none") return "";
|
|
95
|
+
if (mode === "256") return `\x1b[38;5;${to256(rgb)}m`;
|
|
96
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render one line of text with a per-character horizontal gradient.
|
|
101
|
+
* Spaces stay uncolored. `phase` shifts the gradient start (for diagonals).
|
|
102
|
+
* In 'none' mode returns the input text verbatim (no reset appended).
|
|
103
|
+
*/
|
|
104
|
+
export function gradientLine(text, stops, mode, phase = 0) {
|
|
105
|
+
if (mode === "none") return text;
|
|
106
|
+
const chars = [...text];
|
|
107
|
+
const n = Math.max(chars.length - 1, 1);
|
|
108
|
+
let out = "";
|
|
109
|
+
for (let i = 0; i < chars.length; i++) {
|
|
110
|
+
if (chars[i] === " ") {
|
|
111
|
+
out += " ";
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const t = Math.min(i / n + phase, 1);
|
|
115
|
+
out += fg(sampleGradient(stops, t), mode) + chars[i];
|
|
116
|
+
}
|
|
117
|
+
return out + "\x1b[0m";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render a block of lines with a diagonal gradient (each row's phase shifts
|
|
122
|
+
* slightly, approximating the logo's 135° gradient).
|
|
123
|
+
* @returns {string[]}
|
|
124
|
+
*/
|
|
125
|
+
export function gradientBlock(lines, stops, mode) {
|
|
126
|
+
return lines.map((line, row) => gradientLine(line, stops, mode, row * 0.06));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// The mark — block-art interpretation of the ForgeDock "F" (design approved
|
|
131
|
+
// from the 2026-07-03 brainstorm mockups; geometry may be pixel-tuned).
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export const HERO_MARK = [
|
|
135
|
+
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
|
136
|
+
" ▄▄██████████████████",
|
|
137
|
+
" ▄█████████████▀▀▀▀▀▀▀▀▀",
|
|
138
|
+
" ▄██████████▀▀",
|
|
139
|
+
" ▀▀▀▀▀ ▄▄███████▀",
|
|
140
|
+
" ▄████████▀",
|
|
141
|
+
" ▄██████▀",
|
|
142
|
+
" ▀▀▀▀",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export const COMPACT_MARK = [
|
|
146
|
+
" ▄▄████████",
|
|
147
|
+
" ▄█████▀▀▀▀▀",
|
|
148
|
+
" ▀▀ ▄████▀",
|
|
149
|
+
" ▀▀▀",
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the chrome-gradient mark.
|
|
154
|
+
* @param {'hero'|'compact'} size
|
|
155
|
+
* @param {'truecolor'|'256'|'none'} mode
|
|
156
|
+
* @returns {string[]}
|
|
157
|
+
*/
|
|
158
|
+
export function renderMark(size, mode) {
|
|
159
|
+
const art = size === "hero" ? HERO_MARK : COMPACT_MARK;
|
|
160
|
+
if (mode === "none") return [...art];
|
|
161
|
+
return gradientBlock(art, CHROME_STOPS, mode);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Ember-gradient text shorthand (wordmarks, act headers). */
|
|
165
|
+
export function ember(text, mode) {
|
|
166
|
+
return gradientLine(text, EMBER_STOPS, mode);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Promise-based sleep for animation pacing. */
|
|
170
|
+
export function sleep(ms) {
|
|
171
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Animation primitives (Task 2)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
import { box } from "./tui.mjs";
|
|
179
|
+
|
|
180
|
+
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
181
|
+
|
|
182
|
+
/** Ember-tinted status glyph for a result row. */
|
|
183
|
+
function statusGlyph(ok, mode) {
|
|
184
|
+
const rgb = ok ? [255, 179, 71] : [224, 112, 80]; // amber / ember-red
|
|
185
|
+
const glyph = ok ? "✔" : "✖";
|
|
186
|
+
if (mode === "none") return glyph;
|
|
187
|
+
return `${fg(rgb, mode)}${glyph}\x1b[0m`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Dim wrapper honoring the mode gate. */
|
|
191
|
+
function dimText(s, mode) {
|
|
192
|
+
return mode === "none" ? s : `\x1b[2m${s}\x1b[22m`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Badge like [high] / [med] / [low], colorized per confidence. */
|
|
196
|
+
function badgeText(badge, mode) {
|
|
197
|
+
const label = badge === "medium" ? "med" : badge;
|
|
198
|
+
if (mode === "none") return `[${label}]`;
|
|
199
|
+
const rgb = badge === "high" ? [125, 219, 132] : badge === "medium" ? [232, 192, 96] : [224, 112, 80];
|
|
200
|
+
return `${fg(rgb, mode)}[${label}]\x1b[0m`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Ember-bordered fix card (wraps tui.box; border color applied when able). */
|
|
204
|
+
export function fixCard(lines, mode) {
|
|
205
|
+
const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
|
|
206
|
+
if (mode === "none") return boxed;
|
|
207
|
+
const ember = fg([255, 107, 53], mode);
|
|
208
|
+
return boxed
|
|
209
|
+
.split("\n")
|
|
210
|
+
.map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${ember}${m}\x1b[0m`))
|
|
211
|
+
.join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatRow(label, res, mode) {
|
|
215
|
+
const parts = [` ${statusGlyph(res.ok, mode)} ${label.padEnd(16)}`];
|
|
216
|
+
if (res.detail) parts.push(dimText(String(res.detail), mode));
|
|
217
|
+
if (res.badge) parts.push(badgeText(res.badge, mode));
|
|
218
|
+
return parts.join(" ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reveal rows one at a time: spinner while `run()` is in flight, settling to
|
|
223
|
+
* a ✔/✖ line (+ optional fix card). Without motion: run, print final line.
|
|
224
|
+
* Every row's `run` executes regardless of motion — this is display-only.
|
|
225
|
+
*/
|
|
226
|
+
export async function revealRows(
|
|
227
|
+
rows,
|
|
228
|
+
{ mode, motion, writer = process.stdout, interval = 80, minDisplayMs = 120 } = {},
|
|
229
|
+
) {
|
|
230
|
+
const results = [];
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
let res;
|
|
233
|
+
if (!motion) {
|
|
234
|
+
res = await row.run();
|
|
235
|
+
} else {
|
|
236
|
+
let frame = 0;
|
|
237
|
+
const spinnerColor = fg([255, 107, 53], mode);
|
|
238
|
+
const reset = mode === "none" ? "" : "\x1b[0m";
|
|
239
|
+
writer.write(` ${spinnerColor}${BRAILLE[0]}${reset} ${row.label}\n`);
|
|
240
|
+
const timer = setInterval(() => {
|
|
241
|
+
frame = (frame + 1) % BRAILLE.length;
|
|
242
|
+
writer.write(`\x1b[1A\x1b[2K ${spinnerColor}${BRAILLE[frame]}${reset} ${row.label}\n`);
|
|
243
|
+
}, interval);
|
|
244
|
+
const started = Date.now();
|
|
245
|
+
try {
|
|
246
|
+
res = await row.run();
|
|
247
|
+
const elapsed = Date.now() - started;
|
|
248
|
+
if (elapsed < minDisplayMs) await sleep(minDisplayMs - elapsed);
|
|
249
|
+
} finally {
|
|
250
|
+
clearInterval(timer);
|
|
251
|
+
}
|
|
252
|
+
writer.write("\x1b[1A\x1b[2K");
|
|
253
|
+
}
|
|
254
|
+
writer.write(formatRow(row.label, res, mode) + "\n");
|
|
255
|
+
if (!res.ok && res.fix && res.fix.length > 0) {
|
|
256
|
+
writer.write(fixCard(res.fix, mode) + "\n");
|
|
257
|
+
}
|
|
258
|
+
results.push(res);
|
|
259
|
+
}
|
|
260
|
+
return results;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* One-pass highlight sweep over gradient block art, settling to the static
|
|
265
|
+
* gradientBlock render. In-place redraw via cursor-up; never alt-screen.
|
|
266
|
+
*/
|
|
267
|
+
export async function shimmer(
|
|
268
|
+
lines,
|
|
269
|
+
stops,
|
|
270
|
+
{ mode, motion, writer = process.stdout, frames = 14, interval = 55 } = {},
|
|
271
|
+
) {
|
|
272
|
+
const settled = gradientBlock(lines, stops, mode);
|
|
273
|
+
if (!motion || mode === "none") {
|
|
274
|
+
writer.write(settled.join("\n") + "\n");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
278
|
+
for (let f = 0; f <= frames; f++) {
|
|
279
|
+
const band = (f / frames) * (width + 16) - 8;
|
|
280
|
+
const frameLines = lines.map((line, row) => {
|
|
281
|
+
const chars = [...line];
|
|
282
|
+
let out = "";
|
|
283
|
+
for (let i = 0; i < chars.length; i++) {
|
|
284
|
+
if (chars[i] === " ") {
|
|
285
|
+
out += " ";
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const t = Math.min(i / Math.max(chars.length - 1, 1) + row * 0.06, 1);
|
|
289
|
+
let rgb = sampleGradient(stops, t);
|
|
290
|
+
const dist = Math.abs(i - band + row * 1.5);
|
|
291
|
+
if (dist < 6) {
|
|
292
|
+
const boost = 1 - dist / 6;
|
|
293
|
+
rgb = rgb.map((v) => Math.round(v + (255 - v) * boost));
|
|
294
|
+
}
|
|
295
|
+
out += `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${chars[i]}`;
|
|
296
|
+
}
|
|
297
|
+
return out + "\x1b[0m";
|
|
298
|
+
});
|
|
299
|
+
if (f > 0) writer.write(`\x1b[${lines.length}A`);
|
|
300
|
+
writer.write(frameLines.join("\n") + "\n");
|
|
301
|
+
await sleep(interval);
|
|
302
|
+
}
|
|
303
|
+
writer.write(`\x1b[${lines.length}A`);
|
|
304
|
+
writer.write(settled.join("\n") + "\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One static frame of the molten progress bar: solid fill with a ▓▒ leading
|
|
309
|
+
* edge and dim ░ remainder. Caller redraws with cursor-up for animation.
|
|
310
|
+
*/
|
|
311
|
+
export function moltenBar(current, total, { width = 24, mode } = {}) {
|
|
312
|
+
const ratio = total === 0 ? 1 : Math.min(current / total, 1);
|
|
313
|
+
const filled = Math.round(ratio * width);
|
|
314
|
+
const solidLen = Math.max(filled - 2, 0);
|
|
315
|
+
const edge = "▓▒".slice(0, filled - solidLen);
|
|
316
|
+
const body = "█".repeat(solidLen) + edge;
|
|
317
|
+
const rest = "░".repeat(Math.max(width - filled, 0));
|
|
318
|
+
if (mode === "none") return `[${body}${rest}]`;
|
|
319
|
+
return gradientLine(body, EMBER_STOPS, mode) + dimText(rest, mode);
|
|
320
|
+
}
|
package/bin/demo.mjs
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bin/demo.mjs — `forgedock demo` one-command demo mode (issue #1145).
|
|
6
|
+
*
|
|
7
|
+
* Stands up a runnable ForgeDock demo at a PREDICTABLE location with ZERO
|
|
8
|
+
* required decisions, then prints the exact next steps. It removes the
|
|
9
|
+
* clone → cd → init → open friction that loses first-time users.
|
|
10
|
+
*
|
|
11
|
+
* Source precedence (robust + offline-friendly):
|
|
12
|
+
* 1. `git clone` the live demo repo (RapierCraftStudios/forgedock-demo).
|
|
13
|
+
* 2. On failure (repo absent / no network), fall back to copying the bundled
|
|
14
|
+
* scaffold shipped inside the installed package at
|
|
15
|
+
* `FORGE_HOME/examples/forgedock-demo`, then `git init` + initial commit.
|
|
16
|
+
*
|
|
17
|
+
* Re-runs are idempotent: an existing git clone is fast-forwarded
|
|
18
|
+
* (`git pull --ff-only`); a scaffold copy is reused, never clobbered.
|
|
19
|
+
*
|
|
20
|
+
* Design notes:
|
|
21
|
+
* - All side effects (process spawning, filesystem, PATH lookup) are injected
|
|
22
|
+
* via `opts` so the unit tests in bin/tests/demo.test.mjs run with no
|
|
23
|
+
* network and no git/claude on the host.
|
|
24
|
+
* - Pure helpers (parseDemoArgs, resolveTargetDir, renderNextSteps,
|
|
25
|
+
* demoUsage, demoForgeYaml) are exported for direct testing.
|
|
26
|
+
*
|
|
27
|
+
* Exports:
|
|
28
|
+
* DEFAULT_DEMO_REPO, DEFAULT_DEMO_DIRNAME
|
|
29
|
+
* parseDemoArgs(args) → {positional, dir, open, repo, help, error}
|
|
30
|
+
* resolveTargetDir(opts) → string (absolute target path)
|
|
31
|
+
* isGitRepo(dir, fsx) → boolean
|
|
32
|
+
* demoUsage() → string
|
|
33
|
+
* demoForgeYaml(repoSlug) → string
|
|
34
|
+
* renderNextSteps(ctx) → string
|
|
35
|
+
* runDemo(opts) → Promise<{status, ...}>
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
existsSync,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
cpSync,
|
|
42
|
+
readdirSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
} from "fs";
|
|
45
|
+
import { join, dirname, resolve, isAbsolute, basename } from "path";
|
|
46
|
+
import { homedir } from "os";
|
|
47
|
+
import { spawnSync, spawn } from "child_process";
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_DEMO_REPO = "RapierCraftStudios/forgedock-demo";
|
|
50
|
+
export const DEFAULT_DEMO_DIRNAME = "forgedock-demo";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Argument parsing
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse `forgedock demo` arguments.
|
|
58
|
+
*
|
|
59
|
+
* Flags:
|
|
60
|
+
* --dir <path> | --dir=<path> Target directory (default: ~/forgedock-demo).
|
|
61
|
+
* --repo <slug> | --repo=<slug> Override the demo repo (owner/name).
|
|
62
|
+
* --open Launch Claude Code in the demo dir when found.
|
|
63
|
+
* --no-open Explicitly disable launching (the default).
|
|
64
|
+
* --help | -h Show usage.
|
|
65
|
+
*
|
|
66
|
+
* The first non-flag argument is treated as the target directory (positional
|
|
67
|
+
* convenience equivalent to --dir).
|
|
68
|
+
*
|
|
69
|
+
* @param {string[]} args
|
|
70
|
+
* @returns {{positional: string|undefined, dir: string|undefined,
|
|
71
|
+
* repo: string|undefined, open: boolean, help: boolean,
|
|
72
|
+
* error: string|null}}
|
|
73
|
+
*/
|
|
74
|
+
export function parseDemoArgs(args = []) {
|
|
75
|
+
const out = {
|
|
76
|
+
positional: undefined,
|
|
77
|
+
dir: undefined,
|
|
78
|
+
repo: undefined,
|
|
79
|
+
open: false,
|
|
80
|
+
help: false,
|
|
81
|
+
error: null,
|
|
82
|
+
};
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
const a = args[i];
|
|
85
|
+
if (a === "--help" || a === "-h") {
|
|
86
|
+
out.help = true;
|
|
87
|
+
} else if (a === "--open") {
|
|
88
|
+
out.open = true;
|
|
89
|
+
} else if (a === "--no-open") {
|
|
90
|
+
out.open = false;
|
|
91
|
+
} else if (a === "--dir") {
|
|
92
|
+
out.dir = args[++i];
|
|
93
|
+
if (out.dir === undefined) out.error = "--dir requires a path argument";
|
|
94
|
+
} else if (a.startsWith("--dir=")) {
|
|
95
|
+
out.dir = a.slice("--dir=".length);
|
|
96
|
+
} else if (a === "--repo") {
|
|
97
|
+
out.repo = args[++i];
|
|
98
|
+
if (out.repo === undefined) out.error = "--repo requires an owner/name argument";
|
|
99
|
+
} else if (a.startsWith("--repo=")) {
|
|
100
|
+
out.repo = a.slice("--repo=".length);
|
|
101
|
+
} else if (a.startsWith("--")) {
|
|
102
|
+
out.error = `Unknown flag: ${a}`;
|
|
103
|
+
} else if (out.positional === undefined) {
|
|
104
|
+
out.positional = a;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the predictable target directory. Priority: --dir, then positional,
|
|
112
|
+
* then ~/forgedock-demo. Always returned as an absolute path.
|
|
113
|
+
*
|
|
114
|
+
* @param {{dir?: string, positional?: string, home?: string, cwd?: string}} opts
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function resolveTargetDir({ dir, positional, home = homedir(), cwd = process.cwd() } = {}) {
|
|
118
|
+
const chosen = dir || positional;
|
|
119
|
+
if (chosen && chosen.trim() !== "") {
|
|
120
|
+
return isAbsolute(chosen) ? chosen : resolve(cwd, chosen);
|
|
121
|
+
}
|
|
122
|
+
return join(home, DEFAULT_DEMO_DIRNAME);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} dir
|
|
127
|
+
* @param {{existsSync: Function}} fsx
|
|
128
|
+
* @returns {boolean} true if dir contains a `.git` entry (worktree or repo).
|
|
129
|
+
*/
|
|
130
|
+
export function isGitRepo(dir, fsx) {
|
|
131
|
+
return fsx.existsSync(join(dir, ".git"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isEmptyDir(dir, fsx) {
|
|
135
|
+
try {
|
|
136
|
+
return fsx.readdirSync(dir).length === 0;
|
|
137
|
+
} catch {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Rendering
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export function demoUsage() {
|
|
147
|
+
return [
|
|
148
|
+
"Usage: forgedock demo [dir] [--dir <path>] [--repo <owner/name>] [--open]",
|
|
149
|
+
"",
|
|
150
|
+
"Stand up a runnable ForgeDock demo at a predictable location (default",
|
|
151
|
+
"~/forgedock-demo) and print the exact next steps. No configuration needed.",
|
|
152
|
+
"",
|
|
153
|
+
" [dir] Target directory (same as --dir).",
|
|
154
|
+
" --dir <path> Where to create the demo (default: ~/forgedock-demo).",
|
|
155
|
+
" --repo <slug> Demo repo to clone (default: RapierCraftStudios/forgedock-demo).",
|
|
156
|
+
" --open Launch Claude Code in the demo directory if available.",
|
|
157
|
+
" --no-open Do not launch Claude Code (default).",
|
|
158
|
+
" -h, --help Show this help.",
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Minimal, valid forge.yaml for the demo repo so /work-on is usable immediately.
|
|
164
|
+
* @param {string} repoSlug - "owner/name"
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function demoForgeYaml(repoSlug) {
|
|
168
|
+
const [owner = "your-org", repo = DEFAULT_DEMO_DIRNAME] = String(repoSlug).split("/");
|
|
169
|
+
return [
|
|
170
|
+
"# forge.yaml — minimal ForgeDock config for the demo repo.",
|
|
171
|
+
"# Auto-generated by: npx forgedock demo",
|
|
172
|
+
"project:",
|
|
173
|
+
' name: "ForgeDock Demo"',
|
|
174
|
+
` owner: "${owner}"`,
|
|
175
|
+
` repo: "${repo}"`,
|
|
176
|
+
' description: "A tiny Notes API for trying ForgeDock risk-free."',
|
|
177
|
+
"paths:",
|
|
178
|
+
' root: "."',
|
|
179
|
+
' worktree_base: ".claude/worktrees"',
|
|
180
|
+
"branches:",
|
|
181
|
+
' default: "main"',
|
|
182
|
+
' staging: "main"',
|
|
183
|
+
"",
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Render the success / next-steps card.
|
|
189
|
+
* @param {{target: string, source: string, claudeAvailable: boolean}} ctx
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
export function renderNextSteps({ target, source, claudeAvailable }) {
|
|
193
|
+
const sourceNote = {
|
|
194
|
+
cloned: "Cloned the live demo repo.",
|
|
195
|
+
pulled: "Demo already present — pulled the latest.",
|
|
196
|
+
scaffold: "Live repo unavailable — built the demo from the bundled scaffold.",
|
|
197
|
+
exists: "Reused the existing demo directory.",
|
|
198
|
+
}[source] || "";
|
|
199
|
+
|
|
200
|
+
const lines = [
|
|
201
|
+
"",
|
|
202
|
+
`✔ Demo repo ready at ${target}`,
|
|
203
|
+
...(sourceNote ? [` (${sourceNote})`] : []),
|
|
204
|
+
"",
|
|
205
|
+
"Next: open Claude Code in that directory and run:",
|
|
206
|
+
"",
|
|
207
|
+
" /work-on 1",
|
|
208
|
+
"",
|
|
209
|
+
"This picks up a pre-written bug issue and runs the full",
|
|
210
|
+
"investigate → architect → build → review → merge pipeline.",
|
|
211
|
+
"",
|
|
212
|
+
` cd ${target}`,
|
|
213
|
+
claudeAvailable
|
|
214
|
+
? " claude # open Claude Code here (or re-run with --open)"
|
|
215
|
+
: " # then open Claude Code in this directory",
|
|
216
|
+
];
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Default injectable side-effect implementations
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const defaultFsx = { existsSync, mkdirSync, cpSync, readdirSync, writeFileSync };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run a command without throwing. Returns a normalized result.
|
|
228
|
+
* @returns {{status: number, stdout: string, stderr: string}}
|
|
229
|
+
*/
|
|
230
|
+
function defaultExec(cmd, cmdArgs, opts = {}) {
|
|
231
|
+
const r = spawnSync(cmd, cmdArgs, { encoding: "utf8", ...opts });
|
|
232
|
+
return {
|
|
233
|
+
status: r.error ? 127 : (typeof r.status === "number" ? r.status : 1),
|
|
234
|
+
stdout: r.stdout || "",
|
|
235
|
+
stderr: r.stderr || "",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @returns {boolean} whether `bin` resolves on PATH. */
|
|
240
|
+
function defaultCommandExists(bin) {
|
|
241
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
242
|
+
const r = spawnSync(probe, [bin], { encoding: "utf8" });
|
|
243
|
+
return !r.error && r.status === 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Launch an interactive process in `cwd`, inheriting stdio. */
|
|
247
|
+
function defaultLaunch(bin, launchArgs, opts = {}) {
|
|
248
|
+
spawn(bin, launchArgs, { stdio: "inherit", ...opts });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const defaultLogger = {
|
|
252
|
+
log: (m) => process.stdout.write(`${m}\n`),
|
|
253
|
+
error: (m) => process.stderr.write(`${m}\n`),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Orchestrator
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Stand up the demo.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} opts
|
|
264
|
+
* @param {string} opts.forgeHome - Package root (for bundled scaffold).
|
|
265
|
+
* @param {string[]} [opts.args] - CLI args after `demo`.
|
|
266
|
+
* @param {string} [opts.home] - Home dir (default os.homedir()).
|
|
267
|
+
* @param {string} [opts.cwd] - Working dir (default process.cwd()).
|
|
268
|
+
* @param {string} [opts.demoRepo] - Override demo repo slug.
|
|
269
|
+
* @param {Function} [opts.exec] - (cmd, args, {cwd}) => {status,...}.
|
|
270
|
+
* @param {object} [opts.fsx] - Filesystem facade.
|
|
271
|
+
* @param {Function} [opts.commandExists] - (bin) => boolean.
|
|
272
|
+
* @param {Function} [opts.launch] - (bin, args, {cwd}) => void.
|
|
273
|
+
* @param {{log:Function,error:Function}} [opts.logger]
|
|
274
|
+
* @returns {Promise<{status: string, target?: string, source?: string, error?: string}>}
|
|
275
|
+
*/
|
|
276
|
+
export async function runDemo(opts = {}) {
|
|
277
|
+
const {
|
|
278
|
+
forgeHome,
|
|
279
|
+
args = [],
|
|
280
|
+
home = homedir(),
|
|
281
|
+
cwd = process.cwd(),
|
|
282
|
+
exec = defaultExec,
|
|
283
|
+
fsx = defaultFsx,
|
|
284
|
+
commandExists = defaultCommandExists,
|
|
285
|
+
launch = defaultLaunch,
|
|
286
|
+
logger = defaultLogger,
|
|
287
|
+
} = opts;
|
|
288
|
+
|
|
289
|
+
const parsed = parseDemoArgs(args);
|
|
290
|
+
if (parsed.help) {
|
|
291
|
+
logger.log(demoUsage());
|
|
292
|
+
return { status: "help" };
|
|
293
|
+
}
|
|
294
|
+
if (parsed.error) {
|
|
295
|
+
logger.error(parsed.error);
|
|
296
|
+
logger.error(demoUsage());
|
|
297
|
+
return { status: "error", error: parsed.error };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const demoRepo =
|
|
301
|
+
parsed.repo || opts.demoRepo || process.env.FORGEDOCK_DEMO_REPO || DEFAULT_DEMO_REPO;
|
|
302
|
+
const target = resolveTargetDir({ dir: parsed.dir, positional: parsed.positional, home, cwd });
|
|
303
|
+
const scaffold = forgeHome
|
|
304
|
+
? join(forgeHome, "examples", "forgedock-demo")
|
|
305
|
+
: null;
|
|
306
|
+
|
|
307
|
+
let source;
|
|
308
|
+
|
|
309
|
+
if (fsx.existsSync(target) && !isEmptyDir(target, fsx)) {
|
|
310
|
+
// Idempotent re-run — never clobber an existing demo.
|
|
311
|
+
if (isGitRepo(target, fsx)) {
|
|
312
|
+
const pull = exec("git", ["pull", "--ff-only"], { cwd: target });
|
|
313
|
+
source = pull.status === 0 ? "pulled" : "exists";
|
|
314
|
+
if (pull.status !== 0) {
|
|
315
|
+
logger.log("Could not fast-forward the existing demo — leaving it untouched.");
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
source = "exists";
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
// Fresh setup: prefer the live repo, fall back to the bundled scaffold.
|
|
322
|
+
fsx.mkdirSync(dirname(target), { recursive: true });
|
|
323
|
+
const clone = exec(
|
|
324
|
+
"git",
|
|
325
|
+
["clone", "--depth", "1", `https://github.com/${demoRepo}.git`, target],
|
|
326
|
+
{ cwd },
|
|
327
|
+
);
|
|
328
|
+
if (clone.status === 0) {
|
|
329
|
+
source = "cloned";
|
|
330
|
+
} else {
|
|
331
|
+
if (!scaffold || !fsx.existsSync(scaffold)) {
|
|
332
|
+
const msg =
|
|
333
|
+
`Could not clone ${demoRepo} and no bundled scaffold was found` +
|
|
334
|
+
(scaffold ? ` at ${scaffold}` : "") +
|
|
335
|
+
". Check your network or reinstall ForgeDock.";
|
|
336
|
+
logger.error(msg);
|
|
337
|
+
return { status: "error", error: msg };
|
|
338
|
+
}
|
|
339
|
+
// Copy everything except bootstrap.sh (that script is for standing up a
|
|
340
|
+
// *live* GitHub repo and is not needed for a local demo).
|
|
341
|
+
fsx.mkdirSync(target, { recursive: true });
|
|
342
|
+
fsx.cpSync(scaffold, target, {
|
|
343
|
+
recursive: true,
|
|
344
|
+
filter: (src) => basename(src) !== "bootstrap.sh",
|
|
345
|
+
});
|
|
346
|
+
// Best-effort local git history so the demo feels like a real repo.
|
|
347
|
+
exec("git", ["init", "-q"], { cwd: target });
|
|
348
|
+
exec("git", ["add", "."], { cwd: target });
|
|
349
|
+
exec(
|
|
350
|
+
"git",
|
|
351
|
+
[
|
|
352
|
+
"-c",
|
|
353
|
+
"user.name=forgedock-demo",
|
|
354
|
+
"-c",
|
|
355
|
+
"user.email=demo@forgedock.dev",
|
|
356
|
+
"commit",
|
|
357
|
+
"-q",
|
|
358
|
+
"-m",
|
|
359
|
+
"chore: initial demo scaffold",
|
|
360
|
+
],
|
|
361
|
+
{ cwd: target },
|
|
362
|
+
);
|
|
363
|
+
source = "scaffold";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Ensure a usable forge.yaml exists (the bundled scaffold ships without one).
|
|
368
|
+
const forgeYamlPath = join(target, "forge.yaml");
|
|
369
|
+
if (!fsx.existsSync(forgeYamlPath)) {
|
|
370
|
+
fsx.writeFileSync(forgeYamlPath, demoForgeYaml(demoRepo));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const claudeAvailable = commandExists("claude");
|
|
374
|
+
logger.log(renderNextSteps({ target, source, claudeAvailable }));
|
|
375
|
+
|
|
376
|
+
if (parsed.open) {
|
|
377
|
+
if (claudeAvailable) {
|
|
378
|
+
logger.log("Launching Claude Code…");
|
|
379
|
+
launch("claude", [], { cwd: target });
|
|
380
|
+
} else {
|
|
381
|
+
logger.log("`claude` was not found on PATH — open Claude Code manually in the demo directory.");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { status: "ok", target, source };
|
|
386
|
+
}
|