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.
Files changed (76) hide show
  1. package/README.md +227 -174
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/demo.mjs +386 -0
  4. package/bin/engine/phases.mjs +148 -0
  5. package/bin/engine/projector.mjs +32 -0
  6. package/bin/engine/reconcile.mjs +11 -0
  7. package/bin/engine/runlog.mjs +79 -0
  8. package/bin/engine/state.mjs +27 -0
  9. package/bin/engine-cli.mjs +50 -0
  10. package/bin/engine.mjs +138 -0
  11. package/bin/forgedock.mjs +693 -1393
  12. package/bin/init-detect.mjs +95 -0
  13. package/bin/journey.mjs +783 -0
  14. package/bin/runner.mjs +847 -0
  15. package/bin/settings-hook.mjs +112 -0
  16. package/bin/tui.mjs +6 -3
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +85 -39
  19. package/commands/deploy-info.md +11 -5
  20. package/commands/design-bench.md +1 -0
  21. package/commands/design-render-critique-loop.md +1 -0
  22. package/commands/design.md +1 -0
  23. package/commands/forge-stats.md +2 -1
  24. package/commands/issue.md +1 -1
  25. package/commands/optimize.md +1 -1
  26. package/commands/orchestrate.md +346 -34
  27. package/commands/quality-gate.md +168 -80
  28. package/commands/review-pr-agents.md +69 -45
  29. package/commands/review-pr.md +54 -6
  30. package/commands/scope.md +11 -3
  31. package/commands/security-audit.md +24 -16
  32. package/commands/sync-ecosystem.md +2 -2
  33. package/commands/work-on/build/architect.md +47 -3
  34. package/commands/work-on/build/context.md +36 -5
  35. package/commands/work-on/build/implement.md +16 -4
  36. package/commands/work-on/build/validate.md +54 -8
  37. package/commands/work-on/build.md +27 -9
  38. package/commands/work-on/close.md +131 -3
  39. package/commands/work-on/investigate.md +24 -1
  40. package/commands/work-on/review.md +24 -0
  41. package/commands/work-on-monolithic.md +1 -0
  42. package/commands/work-on.md +271 -11
  43. package/docs/BRAND.md +6 -2
  44. package/docs/CONFIG.md +39 -34
  45. package/docs/site/.vitepress/config.mjs +85 -0
  46. package/docs/site/annotations-explained.md +55 -0
  47. package/docs/site/command-learning-path.md +104 -0
  48. package/docs/site/command-reference.md +3 -0
  49. package/docs/site/getting-started.md +42 -13
  50. package/docs/site/index.md +1 -1
  51. package/docs/site/troubleshooting.md +208 -0
  52. package/docs/spec/forge-protocol-v1.md +768 -0
  53. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  54. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  55. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  56. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  57. package/examples/forgedock-demo/README.md +113 -0
  58. package/examples/forgedock-demo/bootstrap.sh +125 -0
  59. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  60. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  61. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  62. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  63. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  64. package/examples/forgedock-demo/labels.json +21 -0
  65. package/examples/forgedock-demo/package.json +16 -0
  66. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  67. package/examples/forgedock-demo/src/auth.js +28 -0
  68. package/examples/forgedock-demo/src/db.js +66 -0
  69. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  70. package/examples/forgedock-demo/src/server.js +79 -0
  71. package/package.json +13 -3
  72. package/scripts/verify-env-vars.sh +18 -2
  73. package/scripts/verify-host-headers.sh +4 -4
  74. package/scripts/verify-route-registration.sh +2 -2
  75. package/scripts/worktree-lifecycle.sh +202 -0
  76. 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
+ }