forgedock 1.1.7 → 1.1.9
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 +10 -33
- package/bin/cinema.mjs +23 -5
- package/bin/engine/invariants.mjs +361 -0
- package/bin/engine-cli.mjs +137 -11
- package/bin/env-detect.mjs +249 -0
- package/bin/forge-utils.mjs +364 -4
- package/bin/forgedock.mjs +1064 -135
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/hooks/session-start.mjs +68 -3
- package/bin/init-detect.mjs +101 -12
- package/bin/journey.mjs +917 -16
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +557 -0
- package/bin/registry.mjs +91 -3
- package/bin/runner.mjs +38 -2
- package/bin/tui.mjs +49 -3
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +8 -8
- package/commands/audit.md +1 -1
- package/commands/autopilot.md +958 -77
- package/commands/ci-audit.md +1 -1
- package/commands/cleanup.md +139 -55
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/explain.md +1 -1
- package/commands/fix-ci.md +1 -1
- package/commands/forgedock-init.md +1 -1
- package/commands/incident-response.md +3 -3
- package/commands/issue.md +1 -1
- package/commands/optimize.md +586 -1
- package/commands/orchestrate/config.md +13 -3
- package/commands/orchestrate/phase-1-resolve.md +36 -26
- package/commands/orchestrate/phase-2-triage.md +1 -1
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +587 -48
- package/commands/orchestrate/phase-4-execution.md +1031 -57
- package/commands/orchestrate/phase-5-cleanup.md +1 -1
- package/commands/orchestrate/phase-6-report.md +248 -11
- package/commands/orchestrate/safety.md +2 -2
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/qa-sweep.md +1 -1
- package/commands/quality-gate.md +418 -1
- package/commands/recover-orphans.md +5 -5
- package/commands/replay.md +1 -1
- package/commands/review-pr-agents/api.md +2 -2
- package/commands/review-pr-agents/auth.md +2 -2
- package/commands/review-pr-agents/billing.md +2 -2
- package/commands/review-pr-agents/concurrency.md +2 -2
- package/commands/review-pr-agents/database.md +2 -2
- package/commands/review-pr-agents/frontend.md +2 -2
- package/commands/review-pr-agents/infra.md +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-agents/scraper.md +2 -2
- package/commands/review-pr-agents/security.md +2 -2
- package/commands/review-pr-staging.md +41 -3
- package/commands/review-pr.md +462 -19
- package/commands/rollback.md +1 -1
- package/commands/scope.md +1 -1
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +429 -0
- package/commands/test-gate.md +1 -1
- package/commands/validate.md +53 -1
- package/commands/work-on/build/architect.md +78 -1
- package/commands/work-on/build/context.md +360 -22
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +29 -1
- package/commands/work-on/build.md +75 -6
- package/commands/work-on/close.md +624 -10
- package/commands/work-on/decompose.md +1 -1
- package/commands/work-on/investigate.md +235 -5
- package/commands/work-on/remediate.md +331 -0
- package/commands/work-on/review.md +1 -1
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +205 -66
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +186 -0
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/site/command-reference.md +31 -1
- package/docs/site/getting-started.md +9 -34
- package/docs/site/index.md +1 -1
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +3 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/check-command-docs-drift.sh +4 -2
- package/scripts/danger-zones.mjs +789 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +245 -6
- package/scripts/refresh-bot-token.sh +0 -97
package/bin/journey.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
|
|
12
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
|
|
13
13
|
import { join, basename } from "path";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -179,14 +179,63 @@ export function manualLowConfidenceKeys(draft, description, values) {
|
|
|
179
179
|
* Back up an existing file to <name>.bak (timestamped if .bak exists).
|
|
180
180
|
* @returns {{ backupName: string } | null} null when the file didn't exist.
|
|
181
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* Back up an existing file to <name>.bak, keeping `.bak` as the newest copy.
|
|
184
|
+
*
|
|
185
|
+
* Regression fix (forge#1850): the previous implementation left the bare
|
|
186
|
+
* `.bak` untouched once created, so only the FIRST-ever clobber's content
|
|
187
|
+
* ever lived there — every later overwrite rotated the (by then already
|
|
188
|
+
* stubbed) current file into a timestamped sibling instead. The net effect
|
|
189
|
+
* was that the newest-looking backup (by filename/mtime) was the most
|
|
190
|
+
* stubbed one, while the one recoverable good copy sat forever under the
|
|
191
|
+
* unindicated bare name. A user restoring "the .bak" or "the newest backup"
|
|
192
|
+
* got garbage either way.
|
|
193
|
+
*
|
|
194
|
+
* Fixed behavior: before writing the current file into `.bak`, rotate any
|
|
195
|
+
* existing `.bak` out to a timestamped file first. `.bak` therefore always
|
|
196
|
+
* holds the immediately-prior state (the best next guess for "undo my last
|
|
197
|
+
* run"), and the full chronological history is preserved in timestamped
|
|
198
|
+
* files — nothing is ever silently buried.
|
|
199
|
+
*
|
|
200
|
+
* @returns {{ backupName: string } | null} null when the file didn't exist.
|
|
201
|
+
*/
|
|
182
202
|
export function backupExisting(outputPath) {
|
|
183
203
|
if (!existsSync(outputPath)) return null;
|
|
184
204
|
const baseBak = outputPath + ".bak";
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
if (existsSync(baseBak)) {
|
|
206
|
+
// Guard against two rotations landing on the same millisecond timestamp
|
|
207
|
+
// (e.g. rapid successive calls in tests, or a fast disk) silently
|
|
208
|
+
// clobbering an already-rotated generation.
|
|
209
|
+
let rotatedPath = `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
210
|
+
let suffix = 1;
|
|
211
|
+
while (existsSync(rotatedPath)) {
|
|
212
|
+
rotatedPath = `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}-${suffix++}`;
|
|
213
|
+
}
|
|
214
|
+
renameSync(baseBak, rotatedPath);
|
|
215
|
+
}
|
|
216
|
+
renameSync(outputPath, baseBak);
|
|
217
|
+
return { backupName: basename(baseBak) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Lightweight forge.yaml presence/shape check for the install receipt
|
|
222
|
+
* (Issue #1946) — no YAML parser dependency, mirrors the regex-based
|
|
223
|
+
* approach already used by resolveLabelsRepo() in bin/forgedock.mjs. Only
|
|
224
|
+
* checks that the three REQUIRED top-level section keys are present; never
|
|
225
|
+
* reads field values or copies file contents into the caller's result.
|
|
226
|
+
* @param {string} cwd
|
|
227
|
+
* @returns {{ present: boolean, validShape: boolean }}
|
|
228
|
+
*/
|
|
229
|
+
export function validateForgeYamlShape(cwd) {
|
|
230
|
+
const p = join(cwd, "forge.yaml");
|
|
231
|
+
if (!existsSync(p)) return { present: false, validShape: false };
|
|
232
|
+
try {
|
|
233
|
+
const raw = readFileSync(p, "utf-8");
|
|
234
|
+
const validShape = /^project:/m.test(raw) && /^paths:/m.test(raw) && /^branches:/m.test(raw);
|
|
235
|
+
return { present: true, validShape };
|
|
236
|
+
} catch {
|
|
237
|
+
return { present: true, validShape: false };
|
|
238
|
+
}
|
|
190
239
|
}
|
|
191
240
|
|
|
192
241
|
// ---------------------------------------------------------------------------
|
|
@@ -244,6 +293,7 @@ import {
|
|
|
244
293
|
renderMark, ember, shimmer, revealRows, moltenBar, fixCard,
|
|
245
294
|
colorMode, motionEnabled, CHROME_STOPS, HERO_MARK, COMPACT_MARK, sleep,
|
|
246
295
|
} from "./cinema.mjs";
|
|
296
|
+
import { detectEnvironment, wslPathToWindows } from "./env-detect.mjs";
|
|
247
297
|
|
|
248
298
|
// ---------------------------------------------------------------------------
|
|
249
299
|
// URL opener (default openFn implementation — injectable for tests)
|
|
@@ -283,6 +333,16 @@ export function makeCtx(overrides = {}) {
|
|
|
283
333
|
const nodeVersion = overrides.nodeVersion ?? process.versions.node;
|
|
284
334
|
const enrichFn = overrides.enrichFn ?? enrich;
|
|
285
335
|
const openFn = overrides.openFn ?? openUrl;
|
|
336
|
+
const confirmFn = overrides.confirmFn ?? confirm;
|
|
337
|
+
const platform = overrides.platform ?? process.platform;
|
|
338
|
+
let release = overrides.release;
|
|
339
|
+
if (release === undefined) {
|
|
340
|
+
try {
|
|
341
|
+
release = os.release();
|
|
342
|
+
} catch {
|
|
343
|
+
release = "";
|
|
344
|
+
}
|
|
345
|
+
}
|
|
286
346
|
return {
|
|
287
347
|
cwd: process.cwd(),
|
|
288
348
|
home: env.HOME || env.USERPROFILE || os.homedir(),
|
|
@@ -293,9 +353,12 @@ export function makeCtx(overrides = {}) {
|
|
|
293
353
|
mode: colorMode(env, stdout),
|
|
294
354
|
motion: motionEnabled(argv, env, stdout),
|
|
295
355
|
nodeVersion,
|
|
356
|
+
platform,
|
|
357
|
+
release,
|
|
296
358
|
linkStrategy: "symlink",
|
|
297
359
|
enrichFn,
|
|
298
360
|
openFn,
|
|
361
|
+
confirmFn,
|
|
299
362
|
exec: (cmd, args) =>
|
|
300
363
|
execFileSync(cmd, args, {
|
|
301
364
|
encoding: "utf-8",
|
|
@@ -315,7 +378,9 @@ const dimLine = (ctx, s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
|
315
378
|
|
|
316
379
|
/**
|
|
317
380
|
* Render the hero banner and run preflight checks. Failures render fix cards
|
|
318
|
-
* and the journey continues — advisory, never fatal.
|
|
381
|
+
* and the journey continues — advisory, never fatal. Includes informational
|
|
382
|
+
* Platform/WSL/Shell rows (via env-detect.mjs) after the pass/fail checks —
|
|
383
|
+
* those three always report `ok: true`, they surface state rather than gate it.
|
|
319
384
|
* @returns {Promise<{ checks: Array<{name, ok, detail, fix?}>, ghReady: boolean }>}
|
|
320
385
|
*/
|
|
321
386
|
export async function preflight(ctx) {
|
|
@@ -323,7 +388,7 @@ export async function preflight(ctx) {
|
|
|
323
388
|
w.write("\n");
|
|
324
389
|
await shimmer(HERO_MARK, CHROME_STOPS, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
325
390
|
w.write("\n " + ember("F O R G E D O C K", ctx.mode) + "\n");
|
|
326
|
-
w.write(" " + dimLine(ctx,
|
|
391
|
+
w.write(" " + dimLine(ctx, `──── ${getLogoTagline("install")} ────────────────────`) + "\n\n");
|
|
327
392
|
|
|
328
393
|
const rows = [
|
|
329
394
|
{
|
|
@@ -377,6 +442,31 @@ export async function preflight(ctx) {
|
|
|
377
442
|
},
|
|
378
443
|
];
|
|
379
444
|
|
|
445
|
+
// Environment reveal rows (platform/WSL/shell) — informational only, never
|
|
446
|
+
// fail the preflight. Appended after the existing 4 checks so `named[3]`
|
|
447
|
+
// (GitHub CLI, used for `ghReady` below) keeps its index.
|
|
448
|
+
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
449
|
+
rows.push(
|
|
450
|
+
{
|
|
451
|
+
label: "Platform",
|
|
452
|
+
run: async () => {
|
|
453
|
+
const wslNote = envInfo.isWSL ? ` (WSL${envInfo.wslDistro ? `: ${envInfo.wslDistro}` : ""})` : "";
|
|
454
|
+
return { ok: true, detail: `${envInfo.platformLabel}${wslNote} (${envInfo.shell})` };
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
label: "WSL",
|
|
459
|
+
run: async () => ({
|
|
460
|
+
ok: true,
|
|
461
|
+
detail: envInfo.isWSL ? envInfo.wslDistro || "detected" : "not detected",
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
label: "Shell",
|
|
466
|
+
run: async () => ({ ok: true, detail: envInfo.shell }),
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
380
470
|
const results = await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
381
471
|
const named = rows.map((r, i) => ({ name: r.label, ...results[i] }));
|
|
382
472
|
return { checks: named, ghReady: named[3].ok };
|
|
@@ -499,6 +589,24 @@ async function saveCopiedManifest(manifestPath, manifest) {
|
|
|
499
589
|
|
|
500
590
|
const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
|
|
501
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Allowlist of universal pipeline-agent scripts installed to
|
|
594
|
+
* ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
|
|
595
|
+
* forgedock.mjs's uninstall(). This is the single source of truth for both
|
|
596
|
+
* directions — re-export from forgedock.mjs rather than duplicating it.
|
|
597
|
+
*
|
|
598
|
+
* Deliberately narrow (forge#715): project-specific/internal tooling that
|
|
599
|
+
* lives in scripts/ (verify-*.sh, doctor-pipeline-state.sh, gen-logo.mjs,
|
|
600
|
+
* the self-dogfooding *.mjs analysis scripts, etc.) must NOT be copied here
|
|
601
|
+
* — only scripts meant to be invoked generically by command specs without
|
|
602
|
+
* knowing ForgeDock's own install path belong in this set.
|
|
603
|
+
*/
|
|
604
|
+
export const PIPELINE_SCRIPTS = new Set([
|
|
605
|
+
"classify-lane.sh",
|
|
606
|
+
"transition-label.sh",
|
|
607
|
+
"validate-pr-target.sh",
|
|
608
|
+
]);
|
|
609
|
+
|
|
502
610
|
/**
|
|
503
611
|
* Recursively walk targetDir and remove any symlink whose target begins with
|
|
504
612
|
* commandsDir (i.e. a ForgeDock-managed link) but whose target file no longer
|
|
@@ -561,6 +669,487 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
|
|
|
561
669
|
return pruned;
|
|
562
670
|
}
|
|
563
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
|
|
674
|
+
* directory rather than a durable install location (global npm install,
|
|
675
|
+
* local repo clone, or a project's own `node_modules`).
|
|
676
|
+
*
|
|
677
|
+
* Used to warn when `ctx.forgeHome` — and therefore the SessionStart hook
|
|
678
|
+
* script path baked into `~/.claude/settings.json` — resolves somewhere that
|
|
679
|
+
* can be silently pruned later (`npm cache clean`, OS temp cleanup, npx's
|
|
680
|
+
* own eviction), which would break context injection with no error surfaced.
|
|
681
|
+
*
|
|
682
|
+
* Matching is path-SEGMENT based (split on `/` and `\`), never a bare
|
|
683
|
+
* substring match — this keeps the check conservative and avoids false
|
|
684
|
+
* positives on a real install whose path merely contains one of these
|
|
685
|
+
* strings (e.g. a project directory named `npx-utils` or `my-dlx-tool`).
|
|
686
|
+
*
|
|
687
|
+
* Recognized shapes:
|
|
688
|
+
* - npm's npx cache: a `_npx` segment — covers both
|
|
689
|
+
* `~/.npm/_npx/<hash>/node_modules/<pkg>` (POSIX) and
|
|
690
|
+
* `%LocalAppData%\npm-cache\_npx\<hash>\node_modules\<pkg>` (Windows).
|
|
691
|
+
* - pnpm's dlx cache: a `dlx` segment — pnpm's ephemeral `dlx` subdir,
|
|
692
|
+
* distinct from `node_modules/.pnpm/` (pnpm's persistent
|
|
693
|
+
* content-addressable store used by ordinary local installs, which must
|
|
694
|
+
* NOT be flagged here).
|
|
695
|
+
* - yarn (Berry) dlx: a segment matching `/^xfs-/i` — `yarn dlx` builds its
|
|
696
|
+
* throwaway project in a temp directory created via `xfs.mktempPromise()`,
|
|
697
|
+
* which yarn names with an `xfs-` prefix.
|
|
698
|
+
*
|
|
699
|
+
* @param {string} p - Absolute path to test (typically `ctx.forgeHome`).
|
|
700
|
+
* @returns {boolean}
|
|
701
|
+
*/
|
|
702
|
+
export function isEphemeralCachePath(p) {
|
|
703
|
+
if (!p || typeof p !== "string") return false;
|
|
704
|
+
const segments = p.split(/[\\/]/).filter(Boolean);
|
|
705
|
+
return segments.some(
|
|
706
|
+
(seg) => seg === "_npx" || seg === "dlx" || /^xfs-/i.test(seg),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
// Act 0/I.5 — Persist Home: copy the toolset into a stable ~/.forge/ (#1943)
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The four top-level directories that make up ForgeDock's installable
|
|
716
|
+
* payload. Kept as a single list so persistHome() and its tests agree on
|
|
717
|
+
* exactly what gets copied.
|
|
718
|
+
*/
|
|
719
|
+
const PERSIST_HOME_DIRS = ["bin", "commands", "scripts", "templates"];
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Detect whether `dir` is a git working tree — has a `.git` entry at all,
|
|
723
|
+
* regardless of whether it's a directory (ordinary clone) or a file (git
|
|
724
|
+
* worktree, whose `.git` is a pointer file back to the main repo's `.git`
|
|
725
|
+
* dir). Both shapes mean "this is a stable, user-owned git checkout" for
|
|
726
|
+
* persistHome()'s purposes — neither should ever be copied into ~/.forge/.
|
|
727
|
+
*
|
|
728
|
+
* Uses lstatSync (not existsSync) so the check reflects the real entry on
|
|
729
|
+
* disk rather than following symlinks, mirroring the detection *style* of
|
|
730
|
+
* resolveRealForgeHome() in bin/forgedock.mjs (which additionally has to
|
|
731
|
+
* distinguish file-vs-directory to resolve worktrees to their main repo
|
|
732
|
+
* root — a distinction persistHome() doesn't need, since both shapes skip
|
|
733
|
+
* identically here).
|
|
734
|
+
*
|
|
735
|
+
* @param {string} dir
|
|
736
|
+
* @returns {boolean}
|
|
737
|
+
*/
|
|
738
|
+
function isGitWorkingTree(dir) {
|
|
739
|
+
try {
|
|
740
|
+
lstatSync(join(dir, ".git"));
|
|
741
|
+
return true;
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Recursively copy `srcDir` into `destDir`, content-comparing existing files
|
|
749
|
+
* before overwriting so unchanged bytes are never rewritten (same idempotency
|
|
750
|
+
* discipline as forge()'s command-linking loop and linkPipelineScripts() —
|
|
751
|
+
* forge#1916). A missing `srcDir` is treated as "nothing to copy" rather than
|
|
752
|
+
* an error, since not every ForgeDock release necessarily ships every one of
|
|
753
|
+
* PERSIST_HOME_DIRS.
|
|
754
|
+
*
|
|
755
|
+
* @param {string} srcDir
|
|
756
|
+
* @param {string} destDir
|
|
757
|
+
* @returns {Promise<{ copied: number, unchanged: number }>}
|
|
758
|
+
*/
|
|
759
|
+
async function copyDirIfChanged(srcDir, destDir) {
|
|
760
|
+
let entries;
|
|
761
|
+
try {
|
|
762
|
+
entries = await readdir(srcDir, { withFileTypes: true });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
if (err.code === "ENOENT") return { copied: 0, unchanged: 0 };
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
await mkdir(destDir, { recursive: true });
|
|
769
|
+
|
|
770
|
+
let copied = 0;
|
|
771
|
+
let unchanged = 0;
|
|
772
|
+
|
|
773
|
+
for (const entry of entries) {
|
|
774
|
+
const srcPath = join(srcDir, entry.name);
|
|
775
|
+
const destPath = join(destDir, entry.name);
|
|
776
|
+
|
|
777
|
+
if (entry.isDirectory()) {
|
|
778
|
+
const sub = await copyDirIfChanged(srcPath, destPath);
|
|
779
|
+
copied += sub.copied;
|
|
780
|
+
unchanged += sub.unchanged;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (!entry.isFile()) continue; // symlinks/sockets/etc. — not expected in this payload
|
|
784
|
+
|
|
785
|
+
let needsCopy = true;
|
|
786
|
+
try {
|
|
787
|
+
const [src, dst] = await Promise.all([readFile(srcPath), readFile(destPath)]);
|
|
788
|
+
if (src.equals(dst)) needsCopy = false;
|
|
789
|
+
} catch (err) {
|
|
790
|
+
if (err.code !== "ENOENT") throw err;
|
|
791
|
+
// destPath missing — needs copy, needsCopy stays true.
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (needsCopy) {
|
|
795
|
+
await copyFile(srcPath, destPath);
|
|
796
|
+
copied++;
|
|
797
|
+
} else {
|
|
798
|
+
unchanged++;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return { copied, unchanged };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Copy ForgeDock's own installable payload (bin/, commands/, scripts/,
|
|
807
|
+
* templates/) from wherever the package currently resolved — npm global
|
|
808
|
+
* install, npx/dlx cache, or any other non-git extraction — into a stable
|
|
809
|
+
* `{ctx.home}/.forge/` home, and point `ctx.forgeHome` at it for the rest of
|
|
810
|
+
* the journey. This is what makes `~/.claude/commands/` symlinks and the
|
|
811
|
+
* SessionStart hook's baked-in script path survive npm/npx cache eviction
|
|
812
|
+
* (issue #1943) — before this, both were built directly from the ephemeral
|
|
813
|
+
* source location and broke silently once that cache was pruned.
|
|
814
|
+
*
|
|
815
|
+
* Skipped entirely when `ctx.forgeHome` is a git working tree (see
|
|
816
|
+
* isGitWorkingTree() above): a git clone (or worktree) is already a stable,
|
|
817
|
+
* user-owned location. Copying it into ~/.forge/ would silently disconnect
|
|
818
|
+
* `git pull`/`npx forgedock update` from what's actually linked into
|
|
819
|
+
* ~/.claude/commands/ — the regression issue #1943's Acceptance Criteria #5
|
|
820
|
+
* explicitly calls out.
|
|
821
|
+
*
|
|
822
|
+
* Content-compares before overwriting (see copyDirIfChanged() above) so
|
|
823
|
+
* steady-state re-runs (every `npx forgedock` invocation) don't rewrite
|
|
824
|
+
* byte-identical files.
|
|
825
|
+
*
|
|
826
|
+
* IMPORTANT: consumes `ctx.forgeHome` exactly as already resolved by the
|
|
827
|
+
* caller — it must never re-derive its own "where does ForgeDock actually
|
|
828
|
+
* live" path. Re-deriving risks reintroducing the worktree-leakage
|
|
829
|
+
* regression fixed by resolveRealForgeHome() (forge#1700): a worktree-scoped
|
|
830
|
+
* FORGE_HOME baked into a persisted copy would dangle once the worktree is
|
|
831
|
+
* removed.
|
|
832
|
+
*
|
|
833
|
+
* Fail-open: any filesystem error (permission denied, disk full, etc.) is
|
|
834
|
+
* caught and reported via the returned `skipped`/`reason` fields rather than
|
|
835
|
+
* thrown. Callers must treat a `skipped: true` result as "fall back to the
|
|
836
|
+
* original ctx.forgeHome" — the pre-existing ephemeral-FORGE_HOME behavior.
|
|
837
|
+
*
|
|
838
|
+
* @param {{ forgeHome: string, home: string }} ctx
|
|
839
|
+
* @returns {Promise<{
|
|
840
|
+
* forgeHome: string,
|
|
841
|
+
* migrated: boolean,
|
|
842
|
+
* skipped: boolean,
|
|
843
|
+
* reason?: string,
|
|
844
|
+
* version: string,
|
|
845
|
+
* filesCopied?: number,
|
|
846
|
+
* filesUnchanged?: number,
|
|
847
|
+
* }>}
|
|
848
|
+
*/
|
|
849
|
+
export async function persistHome(ctx) {
|
|
850
|
+
const source = ctx.forgeHome;
|
|
851
|
+
const persistedHome = join(ctx.home, ".forge");
|
|
852
|
+
|
|
853
|
+
if (isGitWorkingTree(source)) {
|
|
854
|
+
return {
|
|
855
|
+
forgeHome: source,
|
|
856
|
+
migrated: false,
|
|
857
|
+
skipped: true,
|
|
858
|
+
reason: "git working tree — linked directly from the clone, not persisted",
|
|
859
|
+
version: "",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Read the source package's version up front — best-effort, never fatal.
|
|
864
|
+
// A missing/unreadable package.json degrades to an empty version string
|
|
865
|
+
// rather than aborting the whole persist step.
|
|
866
|
+
let version = "";
|
|
867
|
+
try {
|
|
868
|
+
const pkg = JSON.parse(readFileSync(join(source, "package.json"), "utf-8"));
|
|
869
|
+
version = pkg.version || "";
|
|
870
|
+
} catch {
|
|
871
|
+
// proceed with version === ""
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
await mkdir(persistedHome, { recursive: true });
|
|
876
|
+
|
|
877
|
+
let filesCopied = 0;
|
|
878
|
+
let filesUnchanged = 0;
|
|
879
|
+
for (const name of PERSIST_HOME_DIRS) {
|
|
880
|
+
const res = await copyDirIfChanged(join(source, name), join(persistedHome, name));
|
|
881
|
+
filesCopied += res.copied;
|
|
882
|
+
filesUnchanged += res.unchanged;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Also persist package.json itself (not just PERSIST_HOME_DIRS) — several
|
|
886
|
+
// callers read `{forgeHome}/package.json` directly (readForgedockVersion()
|
|
887
|
+
// in this file, used by writeInstallReceipt() — forge#1946) and expect it
|
|
888
|
+
// to resolve relative to whatever ctx.forgeHome currently points at. Once
|
|
889
|
+
// this function reassigns ctx.forgeHome to the persisted copy, those
|
|
890
|
+
// callers would otherwise find no package.json there and silently degrade
|
|
891
|
+
// to an empty version string. Copying it keeps ~/.forge a complete
|
|
892
|
+
// drop-in stand-in for the original forgeHome, not just a commands/hooks
|
|
893
|
+
// mirror. Missing source package.json (unusual layout) is a no-op, same
|
|
894
|
+
// as any other PERSIST_HOME_DIRS entry.
|
|
895
|
+
try {
|
|
896
|
+
const [src, dst] = await Promise.all([
|
|
897
|
+
readFile(join(source, "package.json")),
|
|
898
|
+
readFile(join(persistedHome, "package.json")).catch(() => null),
|
|
899
|
+
]);
|
|
900
|
+
if (!dst || !src.equals(dst)) {
|
|
901
|
+
await copyFile(join(source, "package.json"), join(persistedHome, "package.json"));
|
|
902
|
+
filesCopied++;
|
|
903
|
+
} else {
|
|
904
|
+
filesUnchanged++;
|
|
905
|
+
}
|
|
906
|
+
} catch (err) {
|
|
907
|
+
if (err.code !== "ENOENT") throw err;
|
|
908
|
+
// source package.json missing — nothing to persist, not an error.
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Write ~/.forge/version — content-compared like everything else here so
|
|
912
|
+
// an unchanged version doesn't touch the file's mtime on every re-run.
|
|
913
|
+
const versionPath = join(persistedHome, "version");
|
|
914
|
+
let versionChanged = true;
|
|
915
|
+
try {
|
|
916
|
+
versionChanged = readFileSync(versionPath, "utf-8").trim() !== version;
|
|
917
|
+
} catch {
|
|
918
|
+
// missing/unreadable — treat as changed
|
|
919
|
+
}
|
|
920
|
+
if (versionChanged) {
|
|
921
|
+
const tmpVersionPath = versionPath + ".tmp";
|
|
922
|
+
try {
|
|
923
|
+
writeFileSync(tmpVersionPath, version + "\n", "utf-8");
|
|
924
|
+
renameSync(tmpVersionPath, versionPath);
|
|
925
|
+
} catch (err) {
|
|
926
|
+
try { unlinkSync(tmpVersionPath); } catch { /* best-effort cleanup */ }
|
|
927
|
+
throw err;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
forgeHome: persistedHome,
|
|
933
|
+
migrated: filesCopied > 0 || versionChanged,
|
|
934
|
+
skipped: false,
|
|
935
|
+
version,
|
|
936
|
+
filesCopied,
|
|
937
|
+
filesUnchanged,
|
|
938
|
+
};
|
|
939
|
+
} catch (err) {
|
|
940
|
+
// Fail-open (forge#383): a permission error, disk-full, etc. must never
|
|
941
|
+
// abort install/update — fall back to the original, un-persisted forgeHome.
|
|
942
|
+
return {
|
|
943
|
+
forgeHome: source,
|
|
944
|
+
migrated: false,
|
|
945
|
+
skipped: true,
|
|
946
|
+
reason: `error: ${err && err.message ? err.message : String(err)}`,
|
|
947
|
+
version,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Link the PIPELINE_SCRIPTS allowlist from {forgeHome}/scripts/ into
|
|
954
|
+
* ~/.claude/scripts/, using the same symlink-first / copy-fallback strategy
|
|
955
|
+
* as command installation (isLinkPermissionError-gated). Flat set, no
|
|
956
|
+
* subdirectories, no ownership manifest needed — three well-known filenames.
|
|
957
|
+
*
|
|
958
|
+
* Restores the linkScripts() step (commit 9bf382a, forge#677) that was
|
|
959
|
+
* silently dropped when install moved from the legacy install()/update()
|
|
960
|
+
* flow to this journey-based forge() (forge#1885).
|
|
961
|
+
*
|
|
962
|
+
* @param {{forgeHome: string, home: string, linkStrategy?: string}} ctx
|
|
963
|
+
* @returns {Promise<{installed: number, updated: number, skipped: number, copied: number, total: number}>}
|
|
964
|
+
*/
|
|
965
|
+
async function linkPipelineScripts(ctx) {
|
|
966
|
+
const scriptsSourceDir = join(ctx.forgeHome, "scripts");
|
|
967
|
+
const scriptsTargetDir = join(ctx.home, ".claude", "scripts");
|
|
968
|
+
const wantSymlink = ctx.linkStrategy !== "copy";
|
|
969
|
+
|
|
970
|
+
await mkdir(scriptsTargetDir, { recursive: true });
|
|
971
|
+
|
|
972
|
+
let installed = 0, updated = 0, skipped = 0, copied = 0;
|
|
973
|
+
|
|
974
|
+
for (const name of PIPELINE_SCRIPTS) {
|
|
975
|
+
const file = join(scriptsSourceDir, name);
|
|
976
|
+
if (!existsSync(file)) continue; // source missing (unusual package layout) — skip silently
|
|
977
|
+
|
|
978
|
+
const target = join(scriptsTargetDir, name);
|
|
979
|
+
let existed = false;
|
|
980
|
+
let alreadyCorrect = false;
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
const stats = await lstat(target);
|
|
984
|
+
existed = true;
|
|
985
|
+
if (stats.isSymbolicLink()) {
|
|
986
|
+
const current = await readlink(target);
|
|
987
|
+
if (current === file) alreadyCorrect = true;
|
|
988
|
+
} else if (stats.isFile() && !wantSymlink) {
|
|
989
|
+
// Copy-fallback path (Windows without Developer Mode): content-compare
|
|
990
|
+
// before unlinking/recopying, matching the sibling linkCommands() loop
|
|
991
|
+
// and the legacy linkScripts() this restores (forge#1916).
|
|
992
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
993
|
+
if (src.equals(dst)) alreadyCorrect = true;
|
|
994
|
+
}
|
|
995
|
+
} catch (err) {
|
|
996
|
+
if (err.code !== "ENOENT") throw err;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (alreadyCorrect) {
|
|
1000
|
+
skipped++;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (existed) {
|
|
1005
|
+
await unlink(target).catch((err) => {
|
|
1006
|
+
if (err.code !== "ENOENT") throw err;
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let linked = false;
|
|
1011
|
+
if (wantSymlink) {
|
|
1012
|
+
try {
|
|
1013
|
+
await symlink(file, target);
|
|
1014
|
+
linked = true;
|
|
1015
|
+
} catch (linkErr) {
|
|
1016
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (!linked) {
|
|
1020
|
+
await copyFile(file, target);
|
|
1021
|
+
copied++;
|
|
1022
|
+
} else if (existed) {
|
|
1023
|
+
updated++;
|
|
1024
|
+
} else {
|
|
1025
|
+
installed++;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return { installed, updated, skipped, copied, total: PIPELINE_SCRIPTS.size };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Windows -> WSL UNC probing in detectCrossEnvInstall() below hits a real
|
|
1033
|
+
// blocking syscall per distro/root: fs.existsSync() on a stopped WSL
|
|
1034
|
+
// distro's UNC path triggers Windows to synchronously spin up that distro's
|
|
1035
|
+
// VM before the stat resolves, which can take multiple seconds. Bound both
|
|
1036
|
+
// how many distros get probed and how long the loop is willing to keep
|
|
1037
|
+
// probing before giving up (forge#1917).
|
|
1038
|
+
const CROSS_ENV_PROBE_MAX_DISTROS = 5;
|
|
1039
|
+
const CROSS_ENV_PROBE_BUDGET_MS = 3000;
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Detect whether this repo is also installed from the "other" environment —
|
|
1043
|
+
* WSL vs native Windows — for the SAME physical repo (forge#1893).
|
|
1044
|
+
*
|
|
1045
|
+
* ForgeDock installs are always global (`~/.claude` — see #1589's split-brain
|
|
1046
|
+
* finding: there is no per-repo install state), so there is no per-repo
|
|
1047
|
+
* install record to compare directly. Instead: when the repo itself is
|
|
1048
|
+
* reachable from both sides, the *other* environment's home directory is
|
|
1049
|
+
* directly derivable, and its `.claude/forgedock` manifest dir (written by
|
|
1050
|
+
* `forge()` below) tells us whether ForgeDock has ever run there.
|
|
1051
|
+
*
|
|
1052
|
+
* Two directions:
|
|
1053
|
+
* - WSL → Windows: well-defined. If `ctx.cwd` is under
|
|
1054
|
+
* `/mnt/<drive>/Users/<user>/...`, that's a live bind mount onto the
|
|
1055
|
+
* Windows drive — the Windows home for that same user is
|
|
1056
|
+
* `/mnt/<drive>/Users/<user>`, directly checkable through the mount, no
|
|
1057
|
+
* cross-OS trickery needed.
|
|
1058
|
+
* - Windows → WSL: best-effort. If `ctx.cwd` is under
|
|
1059
|
+
* `<drive>:\Users\<user>\...`, enumerate installed WSL distros
|
|
1060
|
+
* (`wsl -l -q`) and probe each one's
|
|
1061
|
+
* `\\wsl.localhost\<distro>\home\<user>\.claude\forgedock` (falling back
|
|
1062
|
+
* to the older `\\wsl$\<distro>\...` UNC root), guessing the Linux
|
|
1063
|
+
* username equals the Windows username. A wrong guess only produces a
|
|
1064
|
+
* false NEGATIVE (silently skips a real conflict) — never a false
|
|
1065
|
+
* positive, matching this feature's "no false positives" requirement.
|
|
1066
|
+
*
|
|
1067
|
+
* Never throws — any failure (WSL not installed, `wsl -l -q` unavailable,
|
|
1068
|
+
* an inaccessible/slow UNC path, or a `cwd` that doesn't match either shape)
|
|
1069
|
+
* degrades to "no conflict". The Windows -> WSL probe loop is additionally
|
|
1070
|
+
* bounded by `CROSS_ENV_PROBE_MAX_DISTROS` and `CROSS_ENV_PROBE_BUDGET_MS` —
|
|
1071
|
+
* exceeding either bound also degrades to "no conflict" rather than
|
|
1072
|
+
* continuing to block (forge#1917).
|
|
1073
|
+
*
|
|
1074
|
+
* @param {{ cwd: string, exec: (cmd: string, args: string[]) => string }} ctx
|
|
1075
|
+
* @param {import('./env-detect.mjs').EnvironmentInfo} envInfo
|
|
1076
|
+
* @param {{ existsSyncFn?: (p: string) => boolean, nowFn?: () => number }} [deps] - inject
|
|
1077
|
+
* for tests; `existsSyncFn` defaults to the real `fs.existsSync`, `nowFn` defaults to
|
|
1078
|
+
* `Date.now`.
|
|
1079
|
+
* @returns {{ conflict: boolean, otherPath: string | null, direction: "windows" | "wsl" | null }}
|
|
1080
|
+
*/
|
|
1081
|
+
export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
|
|
1082
|
+
const existsSyncFn = deps.existsSyncFn ?? existsSync;
|
|
1083
|
+
const nowFn = deps.nowFn ?? Date.now;
|
|
1084
|
+
const none = { conflict: false, otherPath: null, direction: null };
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
const cwd = String(ctx.cwd ?? "");
|
|
1088
|
+
|
|
1089
|
+
if (envInfo.isWSL) {
|
|
1090
|
+
const m = cwd.match(/^(\/mnt\/[a-z])\/Users\/([^/]+)/i);
|
|
1091
|
+
if (!m) return none;
|
|
1092
|
+
const windowsHomeOnMount = `${m[1]}/Users/${m[2]}`;
|
|
1093
|
+
const checkPath = `${windowsHomeOnMount}/.claude/forgedock`;
|
|
1094
|
+
if (existsSyncFn(checkPath)) {
|
|
1095
|
+
return { conflict: true, otherPath: wslPathToWindows(checkPath) ?? checkPath, direction: "windows" };
|
|
1096
|
+
}
|
|
1097
|
+
return none;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (envInfo.platform === "win32") {
|
|
1101
|
+
const m = cwd.match(/^([a-zA-Z]):[\\/]Users[\\/]([^\\/]+)/i);
|
|
1102
|
+
if (!m) return none;
|
|
1103
|
+
const user = m[2];
|
|
1104
|
+
|
|
1105
|
+
let distros = [];
|
|
1106
|
+
try {
|
|
1107
|
+
const raw = ctx.exec("wsl", ["-l", "-q"]);
|
|
1108
|
+
// `wsl -l -q` prints its distro list as UTF-16LE. `ctx.exec` decodes
|
|
1109
|
+
// process output as UTF-8, so ASCII characters come back interleaved
|
|
1110
|
+
// with NUL bytes (e.g. "Ubuntu" -> "U\0b\0u\0n\0t\0u\0"). Strip them
|
|
1111
|
+
// before splitting into lines.
|
|
1112
|
+
distros = raw
|
|
1113
|
+
.replace(/\0/g, "")
|
|
1114
|
+
.split(/\r?\n/)
|
|
1115
|
+
.map((s) => s.trim())
|
|
1116
|
+
.filter(Boolean);
|
|
1117
|
+
} catch {
|
|
1118
|
+
return none; // WSL not installed, or `wsl` isn't on PATH — no conflict possible.
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Bound worst-case blocking time: cap how many distros get probed, and
|
|
1122
|
+
// stop probing once the wall-clock budget is exhausted. Each existsSync
|
|
1123
|
+
// call below is a real blocking syscall — a stopped WSL distro auto-
|
|
1124
|
+
// starts its VM synchronously to service the UNC stat, which can take
|
|
1125
|
+
// multiple seconds. Checking the deadline before every probe (not just
|
|
1126
|
+
// once per distro) stops the delay from compounding once the budget
|
|
1127
|
+
// is gone; it can't preempt a single already-in-flight call, but it
|
|
1128
|
+
// does bound the total across distros/roots (forge#1917).
|
|
1129
|
+
const probeDeadline = nowFn() + CROSS_ENV_PROBE_BUDGET_MS;
|
|
1130
|
+
for (const distro of distros.slice(0, CROSS_ENV_PROBE_MAX_DISTROS)) {
|
|
1131
|
+
if (nowFn() > probeDeadline) break;
|
|
1132
|
+
for (const root of [`\\\\wsl.localhost\\${distro}`, `\\\\wsl$\\${distro}`]) {
|
|
1133
|
+
if (nowFn() > probeDeadline) break;
|
|
1134
|
+
const checkPath = `${root}\\home\\${user}\\.claude\\forgedock`;
|
|
1135
|
+
try {
|
|
1136
|
+
if (existsSyncFn(checkPath)) {
|
|
1137
|
+
return { conflict: true, otherPath: checkPath, direction: "wsl" };
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
// Inaccessible/unresponsive UNC path — treat as not found, try the next one.
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return none;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return none;
|
|
1148
|
+
} catch {
|
|
1149
|
+
return none;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
564
1153
|
/**
|
|
565
1154
|
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
566
1155
|
* then register the SessionStart hook.
|
|
@@ -706,6 +1295,9 @@ export async function forge(ctx) {
|
|
|
706
1295
|
// target file no longer exists (e.g. after a command is renamed or deleted).
|
|
707
1296
|
const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
|
|
708
1297
|
|
|
1298
|
+
// Link the small set of universal pipeline-agent scripts (forge#1885).
|
|
1299
|
+
const scriptsResult = await linkPipelineScripts(ctx);
|
|
1300
|
+
|
|
709
1301
|
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
710
1302
|
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
711
1303
|
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
@@ -749,6 +1341,13 @@ export async function forge(ctx) {
|
|
|
749
1341
|
if (copied > 0) {
|
|
750
1342
|
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
751
1343
|
}
|
|
1344
|
+
if (scriptsResult.total > 0) {
|
|
1345
|
+
const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
|
|
1346
|
+
const scriptsDetail = scriptsChanged > 0
|
|
1347
|
+
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
1348
|
+
: `(unchanged ${scriptsResult.skipped})`;
|
|
1349
|
+
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
|
|
1350
|
+
}
|
|
752
1351
|
if (manifestSaveFailed) {
|
|
753
1352
|
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
754
1353
|
}
|
|
@@ -759,6 +1358,60 @@ export async function forge(ctx) {
|
|
|
759
1358
|
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
760
1359
|
}
|
|
761
1360
|
|
|
1361
|
+
// Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
|
|
1362
|
+
// runJourney() calls persistHome(ctx) right before forge() and stashes its
|
|
1363
|
+
// result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
|
|
1364
|
+
// wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
|
|
1365
|
+
// stable ~/.forge/ copy, so report that outcome instead of the raw
|
|
1366
|
+
// ephemeral-cache warning. Direct forge() callers that never set
|
|
1367
|
+
// ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
|
|
1368
|
+
// through to the original ephemeral-cache-only check unchanged.
|
|
1369
|
+
const persistResult = ctx.persistHomeResult;
|
|
1370
|
+
if (persistResult && !persistResult.skipped) {
|
|
1371
|
+
if (persistResult.migrated) {
|
|
1372
|
+
w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
|
|
1373
|
+
} else {
|
|
1374
|
+
w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
|
|
1375
|
+
}
|
|
1376
|
+
} else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
|
|
1377
|
+
// Skipped for a reason OTHER than "still ephemeral" — today that's always
|
|
1378
|
+
// the git-working-tree exemption (Acceptance Criteria #5). Informational,
|
|
1379
|
+
// not a warning: this is the expected, correct state for a git-clone install.
|
|
1380
|
+
w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
|
|
1381
|
+
} else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
|
|
1382
|
+
// Either persistHome() was never run (no ctx.persistHomeResult), or it
|
|
1383
|
+
// ran but failed and left ctx.forgeHome pointing at the original
|
|
1384
|
+
// ephemeral source (fail-open — see persistHome()'s error branch).
|
|
1385
|
+
w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
|
|
1386
|
+
w.write(fixCard([
|
|
1387
|
+
"This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
|
|
1388
|
+
"or OS temp cleanup. If that happens, the SessionStart hook silently stops",
|
|
1389
|
+
"injecting ForgeDock context — no error is shown.",
|
|
1390
|
+
"Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
|
|
1391
|
+
], ctx.mode, "warning") + "\n");
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Cross-environment install advisory (forge#1893): warn when this repo is
|
|
1395
|
+
// reachable from both WSL and native Windows and BOTH sides already have a
|
|
1396
|
+
// ForgeDock install. Each environment's install is fully independent (see
|
|
1397
|
+
// #1589 — installs are always global, never per-repo), so running from
|
|
1398
|
+
// both silently leaves forge.yaml/hook paths pointing at whichever side ran
|
|
1399
|
+
// most recently, clobbering the other's config with no error surfaced.
|
|
1400
|
+
// Advisory only — never fails the install.
|
|
1401
|
+
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
1402
|
+
const crossEnv = detectCrossEnvInstall(ctx, envInfo);
|
|
1403
|
+
if (crossEnv.conflict) {
|
|
1404
|
+
const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
|
|
1405
|
+
w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
|
|
1406
|
+
w.write(fixCard([
|
|
1407
|
+
`This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
|
|
1408
|
+
"install already exists. Each side's install is independent — running",
|
|
1409
|
+
"forgedock from both can leave forge.yaml and hook paths pointing at",
|
|
1410
|
+
"whichever environment ran last, silently overwriting the other's config.",
|
|
1411
|
+
"Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
|
|
1412
|
+
], ctx.mode, "warning") + "\n");
|
|
1413
|
+
}
|
|
1414
|
+
|
|
762
1415
|
// Report enforcement hook status.
|
|
763
1416
|
if (preToolUseStatus !== null) {
|
|
764
1417
|
w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
|
|
@@ -775,7 +1428,7 @@ export async function forge(ctx) {
|
|
|
775
1428
|
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
776
1429
|
}
|
|
777
1430
|
|
|
778
|
-
return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
|
|
1431
|
+
return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
779
1432
|
}
|
|
780
1433
|
|
|
781
1434
|
// ---------------------------------------------------------------------------
|
|
@@ -784,7 +1437,7 @@ export async function forge(ctx) {
|
|
|
784
1437
|
|
|
785
1438
|
import { detectConfig } from "./init-detect.mjs";
|
|
786
1439
|
import { enrich } from "./init-enrich-api.mjs";
|
|
787
|
-
import { annotatedReviewScreen, box, confirm } from "./tui.mjs";
|
|
1440
|
+
import { annotatedReviewScreen, box, confirm, getLogoTagline } from "./tui.mjs";
|
|
788
1441
|
|
|
789
1442
|
const badgeOf = (field) => field.confidence;
|
|
790
1443
|
|
|
@@ -837,6 +1490,13 @@ export async function read(ctx) {
|
|
|
837
1490
|
* Act IV: the single interaction. Non-TTY + existing config aborts to protect
|
|
838
1491
|
* the file; non-TTY + fresh config writes detection values with TODO flags
|
|
839
1492
|
* (annotatedReviewScreen's non-TTY path returns them directly).
|
|
1493
|
+
*
|
|
1494
|
+
* Overwrite protection (forge#1850, regression of #578): a TTY session with
|
|
1495
|
+
* an existing forge.yaml must give EXPLICIT default-No consent before any
|
|
1496
|
+
* backup or write happens — the review screen's own "Overwrite Mode" banner
|
|
1497
|
+
* is advisory, not consent. This restores the #578 gate via ctx.confirmFn
|
|
1498
|
+
* (defaults to tui.mjs's confirm(), which is non-TTY-safe on its own —
|
|
1499
|
+
* belt-and-suspenders with the explicit isTTY check above it).
|
|
840
1500
|
*/
|
|
841
1501
|
export async function review(ctx, draft, description) {
|
|
842
1502
|
const { stdout: w } = ctx;
|
|
@@ -849,6 +1509,17 @@ export async function review(ctx, draft, description) {
|
|
|
849
1509
|
return { written: false, todoCount: 0, backupName: null, aborted: true };
|
|
850
1510
|
}
|
|
851
1511
|
|
|
1512
|
+
if (hasExisting) {
|
|
1513
|
+
const confirmed = await ctx.confirmFn(
|
|
1514
|
+
"forge.yaml already exists. Overwrite it? A backup will be created.",
|
|
1515
|
+
false,
|
|
1516
|
+
);
|
|
1517
|
+
if (!confirmed) {
|
|
1518
|
+
w.write("\n Overwrite cancelled — forge.yaml left untouched.\n");
|
|
1519
|
+
return { written: false, todoCount: 0, backupName: null, aborted: true };
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
852
1523
|
const extraFields = description.value
|
|
853
1524
|
? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
|
|
854
1525
|
: {};
|
|
@@ -859,6 +1530,19 @@ export async function review(ctx, draft, description) {
|
|
|
859
1530
|
extraFields,
|
|
860
1531
|
});
|
|
861
1532
|
|
|
1533
|
+
// Hard-fail on unresolved identity placeholders when OVERWRITING an
|
|
1534
|
+
// existing config (forge#1850 spec item 4): never destroy a working
|
|
1535
|
+
// forge.yaml in favor of one that can't address a repo. A brand-new
|
|
1536
|
+
// (non-existing) config is allowed to write placeholders — that's the
|
|
1537
|
+
// existing, already-flagged (# TODO comments) greenfield-setup path, and
|
|
1538
|
+
// there's nothing valuable to lose. Checked AFTER the review screen so an
|
|
1539
|
+
// interactive edit that fixes the field still passes.
|
|
1540
|
+
if (hasExisting && (accepted.owner === "your-github-org" || accepted.repo === "your-repo-name")) {
|
|
1541
|
+
w.write("\n Could not determine the GitHub owner/repo for this project.\n");
|
|
1542
|
+
w.write(" " + dimLine(ctx, "Run this inside the target git repository, or edit those fields before accepting.") + "\n");
|
|
1543
|
+
return { written: false, todoCount: 0, backupName: null, aborted: true };
|
|
1544
|
+
}
|
|
1545
|
+
|
|
862
1546
|
const backup = backupExisting(outputPath);
|
|
863
1547
|
if (backup) w.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
864
1548
|
|
|
@@ -894,6 +1578,9 @@ export function celebrate(ctx, summary) {
|
|
|
894
1578
|
const hookNote = summary.hookStatus === "skipped-malformed" ? "hook NOT active — see fix above" : "Claude Code knows this repo";
|
|
895
1579
|
w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
|
|
896
1580
|
}
|
|
1581
|
+
if (summary.scriptsResult && summary.scriptsResult.total > 0) {
|
|
1582
|
+
w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, "~/.claude/scripts — classify-lane, transition-label, validate-pr-target")}\n`);
|
|
1583
|
+
}
|
|
897
1584
|
w.write("\n");
|
|
898
1585
|
if (summary.isMinimal) {
|
|
899
1586
|
w.write(" " + dimLine(ctx, "see docs/CONFIG.md for optional sections") + "\n");
|
|
@@ -906,10 +1593,13 @@ export function celebrate(ctx, summary) {
|
|
|
906
1593
|
` 3. run npx forgedock doctor — verify the install is green`,
|
|
907
1594
|
` 4. open claude in this repo`,
|
|
908
1595
|
` 5. run /issue <title> then /work-on <number>`,
|
|
1596
|
+
` 6. run npx forgedock run-issue <N> — drive an issue via the durable engine`,
|
|
1597
|
+
` 7. run npx forgedock watch — monitor pipeline state`,
|
|
909
1598
|
],
|
|
910
1599
|
{ title: "what's next" },
|
|
911
1600
|
),
|
|
912
1601
|
);
|
|
1602
|
+
w.write(" " + dimLine(ctx, "not for you? npx forgedock uninstall removes everything · full command list: npx forgedock help") + "\n");
|
|
913
1603
|
w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
|
|
914
1604
|
}
|
|
915
1605
|
|
|
@@ -925,8 +1615,18 @@ const GITHUB_APP_URL = "https://github.com/apps/rapiercraft-forgedock/installati
|
|
|
925
1615
|
* the journey without error.
|
|
926
1616
|
*
|
|
927
1617
|
* Uses ctx.openFn (injectable for tests) to open the URL in the default
|
|
928
|
-
* browser
|
|
929
|
-
*
|
|
1618
|
+
* browser, and ctx.confirmFn (injectable for tests) to ask for consent. In
|
|
1619
|
+
* non-TTY environments (piped stdin, --fast) confirm() resolves immediately
|
|
1620
|
+
* to false (the defaultValue), so no prompt is shown.
|
|
1621
|
+
*
|
|
1622
|
+
* IMPORTANT: installing the app today only registers it against the
|
|
1623
|
+
* account/org — it does NOT mint a bot token, does NOT auto-refresh
|
|
1624
|
+
* anything, and does NOT change which `gh` auth pipeline commands use.
|
|
1625
|
+
* Minting an installation token requires the app's private key, which only
|
|
1626
|
+
* RapierCraft Studios (the app owner) holds — an end user's installation
|
|
1627
|
+
* cannot self-serve a token without a hosted minting backend, which does
|
|
1628
|
+
* not exist yet (forge#1890). Keep this prompt's copy honest about that
|
|
1629
|
+
* until such a backend ships — see docs/CONFIG.md "GitHub App Install".
|
|
930
1630
|
*
|
|
931
1631
|
* @returns {Promise<{ opened: boolean }>}
|
|
932
1632
|
*/
|
|
@@ -934,8 +1634,8 @@ export async function connect(ctx) {
|
|
|
934
1634
|
const { stdout: w } = ctx;
|
|
935
1635
|
w.write("\n " + ember("Connect to GitHub", ctx.mode) + "\n\n");
|
|
936
1636
|
|
|
937
|
-
const yes = await
|
|
938
|
-
"Install the ForgeDock GitHub App
|
|
1637
|
+
const yes = await ctx.confirmFn(
|
|
1638
|
+
"Install the ForgeDock GitHub App on this account/org?",
|
|
939
1639
|
false,
|
|
940
1640
|
);
|
|
941
1641
|
|
|
@@ -943,7 +1643,8 @@ export async function connect(ctx) {
|
|
|
943
1643
|
ctx.openFn(GITHUB_APP_URL);
|
|
944
1644
|
w.write(
|
|
945
1645
|
` \x1b[38;2;255;179;71m✔\x1b[0m Opening ${GITHUB_APP_URL}\n` +
|
|
946
|
-
` ${ctx.mode === "none" ? "" : "\x1b[2m"}
|
|
1646
|
+
` ${ctx.mode === "none" ? "" : "\x1b[2m"}This registers the app only — it does not create a bot token yet.\x1b[22m\n` +
|
|
1647
|
+
` ${ctx.mode === "none" ? "" : "\x1b[2m"}Pipeline commands keep using your personal \`gh\` auth. See docs/CONFIG.md.\x1b[22m\n`,
|
|
947
1648
|
);
|
|
948
1649
|
return { opened: true };
|
|
949
1650
|
}
|
|
@@ -952,6 +1653,193 @@ export async function connect(ctx) {
|
|
|
952
1653
|
return { opened: false };
|
|
953
1654
|
}
|
|
954
1655
|
|
|
1656
|
+
// ---------------------------------------------------------------------------
|
|
1657
|
+
// Install Receipt (Issue #1946) — machine-readable record of what an
|
|
1658
|
+
// install/update actually did, so drift debugging can read a receipt instead
|
|
1659
|
+
// of re-deriving state from scratch.
|
|
1660
|
+
// ---------------------------------------------------------------------------
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Read the installed forgedock package version from {forgeHome}/package.json.
|
|
1664
|
+
* Best-effort — returns "" on any read/parse failure. Duplicated (rather than
|
|
1665
|
+
* imported) from bin/forgedock.mjs's getVersion() to avoid a circular import:
|
|
1666
|
+
* forgedock.mjs already imports from journey.mjs.
|
|
1667
|
+
* @param {string} forgeHome
|
|
1668
|
+
* @returns {string}
|
|
1669
|
+
*/
|
|
1670
|
+
function readForgedockVersion(forgeHome) {
|
|
1671
|
+
try {
|
|
1672
|
+
const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
|
|
1673
|
+
return typeof pkg.version === "string" ? pkg.version : "";
|
|
1674
|
+
} catch {
|
|
1675
|
+
return "";
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Write a machine-readable install-receipt.json to {ctx.home}/.forge/ after a
|
|
1681
|
+
* successful install (runJourney) or update (bin/forgedock.mjs's
|
|
1682
|
+
* relinkAndHint — shared by both update() branches: the git-clone
|
|
1683
|
+
* fast-forward path and the npm version-check path). Note: re-running
|
|
1684
|
+
* `npx forgedock install` on an already-managed-active repo takes the
|
|
1685
|
+
* statusScreen() short-circuit instead of runJourney()/relinkAndHint() — that
|
|
1686
|
+
* path does not refresh the receipt (it also does not touch forge() or
|
|
1687
|
+
* anything else, so this is consistent with the rest of that short-circuit's
|
|
1688
|
+
* no-op behavior, not a gap specific to this feature). See docs/CONFIG.md
|
|
1689
|
+
* "Install Receipt" for the schema.
|
|
1690
|
+
*
|
|
1691
|
+
* Deliberately narrow field set — no PII/secrets: no process.env values, no
|
|
1692
|
+
* GitHub tokens, no forge.yaml file contents (only a presence/shape boolean
|
|
1693
|
+
* from validateForgeYamlShape()). Absolute paths (forgeHome/cwd) ARE included
|
|
1694
|
+
* for drift debugging; they are not secrets, matching existing precedent
|
|
1695
|
+
* (ctx.cwd/ctx.home already appear in on-disk state such as registry.json's
|
|
1696
|
+
* path-keyed entries).
|
|
1697
|
+
*
|
|
1698
|
+
* Never throws: any failure (permission denied, disk full, malformed
|
|
1699
|
+
* forgeHome) degrades to a silent no-op, matching every other housekeeping
|
|
1700
|
+
* step in forge() (manifest save, hook install) — a receipt write must never
|
|
1701
|
+
* fail the install/update it merely records.
|
|
1702
|
+
*
|
|
1703
|
+
* @param {object} ctx - Journey context (forgeHome, home, cwd, platform, env,
|
|
1704
|
+
* release, includeExtras — see makeCtx()).
|
|
1705
|
+
* @param {{ forged?: Awaited<ReturnType<typeof forge>> }} summary
|
|
1706
|
+
* `forged` is forge()'s return value (hook statuses, script results). The
|
|
1707
|
+
* caller does not need to pass `reviewed` — forgeYaml status is recomputed
|
|
1708
|
+
* independently via validateForgeYamlShape() so this works identically
|
|
1709
|
+
* whether called from runJourney() (after Act III/IV) or relinkAndHint()
|
|
1710
|
+
* (which never reaches Act III/IV).
|
|
1711
|
+
* @returns {Promise<{ written: boolean, path: string }>}
|
|
1712
|
+
*/
|
|
1713
|
+
export async function writeInstallReceipt(ctx, summary = {}) {
|
|
1714
|
+
// Computed inside the try block (not before it): ctx.home is expected to
|
|
1715
|
+
// always be a string in production (makeCtx()/ctx() both default it via
|
|
1716
|
+
// env.HOME || env.USERPROFILE || os.homedir()), but this function's own
|
|
1717
|
+
// contract is "never throws" — join() on a malformed ctx.home must degrade
|
|
1718
|
+
// to written:false, not escape uncaught to the CLI entrypoint.
|
|
1719
|
+
let receiptPath = join(os.homedir(), ".forge", "install-receipt.json");
|
|
1720
|
+
try {
|
|
1721
|
+
receiptPath = join(ctx.home, ".forge", "install-receipt.json");
|
|
1722
|
+
const { forged = {} } = summary;
|
|
1723
|
+
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
1724
|
+
const installMode = existsSync(join(ctx.forgeHome, ".git")) ? "git-clone" : "npm";
|
|
1725
|
+
const tier = ctx.includeExtras ? "extras" : "core";
|
|
1726
|
+
|
|
1727
|
+
const commandsDir = join(ctx.forgeHome, "commands");
|
|
1728
|
+
let commands = [];
|
|
1729
|
+
if (existsSync(commandsDir)) {
|
|
1730
|
+
try {
|
|
1731
|
+
const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
|
|
1732
|
+
commands = files.map((f) => relative(commandsDir, f).replace(/\.md$/, "").replace(/\\/g, "/"));
|
|
1733
|
+
} catch {
|
|
1734
|
+
commands = [];
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const receipt = {
|
|
1739
|
+
schemaVersion: 1,
|
|
1740
|
+
timestamp: new Date().toISOString(),
|
|
1741
|
+
forgedockVersion: readForgedockVersion(ctx.forgeHome),
|
|
1742
|
+
installMode,
|
|
1743
|
+
forgeHome: ctx.forgeHome,
|
|
1744
|
+
cwd: ctx.cwd,
|
|
1745
|
+
platform: {
|
|
1746
|
+
platform: envInfo.platform,
|
|
1747
|
+
platformLabel: envInfo.platformLabel,
|
|
1748
|
+
isWSL: envInfo.isWSL,
|
|
1749
|
+
wslDistro: envInfo.wslDistro,
|
|
1750
|
+
shell: envInfo.shell,
|
|
1751
|
+
},
|
|
1752
|
+
tier,
|
|
1753
|
+
commands: { count: commands.length, list: commands },
|
|
1754
|
+
hooks: {
|
|
1755
|
+
sessionStart: forged.hookStatus ?? null,
|
|
1756
|
+
preToolUse: forged.preToolUseStatus ?? null,
|
|
1757
|
+
subagentStopEnforce: forged.subagentStopEnforceStatus ?? null,
|
|
1758
|
+
},
|
|
1759
|
+
forgeYaml: validateForgeYamlShape(ctx.cwd),
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
await mkdir(join(ctx.home, ".forge"), { recursive: true });
|
|
1763
|
+
const tmpPath = receiptPath + ".tmp";
|
|
1764
|
+
await writeFile(tmpPath, JSON.stringify(receipt, null, 2) + "\n", "utf-8");
|
|
1765
|
+
await rename(tmpPath, receiptPath);
|
|
1766
|
+
return { written: true, path: receiptPath };
|
|
1767
|
+
} catch {
|
|
1768
|
+
// Best-effort only — a receipt write failure must never fail install/update.
|
|
1769
|
+
return { written: false, path: receiptPath };
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ---------------------------------------------------------------------------
|
|
1774
|
+
// Act V.6 — Proactive demo offer (Issue #1945)
|
|
1775
|
+
// ---------------------------------------------------------------------------
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Act V.6: after a fresh (non-update) install, proactively offer the
|
|
1779
|
+
* risk-free interactive demo (`bin/demo.mjs`, issue #1145) instead of leaving
|
|
1780
|
+
* it as an undiscoverable opt-in buried in help text.
|
|
1781
|
+
*
|
|
1782
|
+
* Modeled directly on `connect()` above: `ctx.confirmFn` defaults to
|
|
1783
|
+
* `false` and is already non-TTY-safe (see `tui.mjs`'s `confirm()` — a
|
|
1784
|
+
* non-interactive stdin resolves immediately to the default, no prompt is
|
|
1785
|
+
* shown). No separate `--fast`/non-TTY guard is needed here for the same
|
|
1786
|
+
* reason `connect()` doesn't need one.
|
|
1787
|
+
*
|
|
1788
|
+
* `ctx.runDemoFn` is injectable (defaults to a dynamic `import("./demo.mjs")`
|
|
1789
|
+
* wrapper, the same call shape `demo()` in bin/forgedock.mjs already uses) so
|
|
1790
|
+
* tests can stub it without spawning real `git clone`/network calls.
|
|
1791
|
+
* `runDemo()` reports failure via a returned `{status: "error"}` rather than
|
|
1792
|
+
* throwing (see bin/demo.mjs), so both the returned status and a thrown
|
|
1793
|
+
* exception are treated as "could not start the demo" here.
|
|
1794
|
+
*
|
|
1795
|
+
* Never throws past this function: a failed dynamic import, a thrown error,
|
|
1796
|
+
* or an error status from `runDemo()` must not turn an otherwise-successful
|
|
1797
|
+
* install into a non-zero exit — same best-effort posture as `openUrl()`.
|
|
1798
|
+
*
|
|
1799
|
+
* Only ever called from genuinely fresh/reconfigure flows (`runJourney()`
|
|
1800
|
+
* below, and `initFlow()` in bin/forgedock.mjs) — `npx forgedock update`
|
|
1801
|
+
* calls `relinkAndHint()` instead of `celebrate()`/this function, so the
|
|
1802
|
+
* repair path is structurally excluded already.
|
|
1803
|
+
*
|
|
1804
|
+
* @param {{confirmFn: Function, forgeHome: string, cwd: string, stdout: object,
|
|
1805
|
+
* mode: string, runDemoFn?: Function}} ctx
|
|
1806
|
+
* @returns {Promise<{ offered: boolean, ranDemo: boolean }>}
|
|
1807
|
+
*/
|
|
1808
|
+
export async function maybeOfferDemo(ctx) {
|
|
1809
|
+
const { stdout: w } = ctx;
|
|
1810
|
+
const dim = (s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
1811
|
+
|
|
1812
|
+
let yes = false;
|
|
1813
|
+
try {
|
|
1814
|
+
yes = await ctx.confirmFn("Run the interactive demo now?", false);
|
|
1815
|
+
} catch {
|
|
1816
|
+
return { offered: false, ranDemo: false };
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (!yes) {
|
|
1820
|
+
w.write(" " + dim("Skipped — try it anytime: npx forgedock demo") + "\n");
|
|
1821
|
+
return { offered: true, ranDemo: false };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
const runDemoFn = ctx.runDemoFn ?? (async (opts) => {
|
|
1825
|
+
const { runDemo } = await import("./demo.mjs");
|
|
1826
|
+
return runDemo(opts);
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
try {
|
|
1830
|
+
const result = await runDemoFn({ forgeHome: ctx.forgeHome, cwd: ctx.cwd });
|
|
1831
|
+
if (result && result.status === "error") {
|
|
1832
|
+
w.write(" " + dim(`Could not start the demo — try it later: npx forgedock demo (${result.error || "unknown error"})`) + "\n");
|
|
1833
|
+
return { offered: true, ranDemo: false };
|
|
1834
|
+
}
|
|
1835
|
+
return { offered: true, ranDemo: true };
|
|
1836
|
+
} catch {
|
|
1837
|
+
// Best-effort — a failed demo run must not fail the install itself.
|
|
1838
|
+
w.write(" " + dim("Could not start the demo — try it later: npx forgedock demo") + "\n");
|
|
1839
|
+
return { offered: true, ranDemo: false };
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
955
1843
|
// ---------------------------------------------------------------------------
|
|
956
1844
|
// The full journey (Task 8)
|
|
957
1845
|
// ---------------------------------------------------------------------------
|
|
@@ -968,11 +1856,24 @@ export async function runJourney(ctx) {
|
|
|
968
1856
|
process.on("SIGINT", onSigint);
|
|
969
1857
|
try {
|
|
970
1858
|
await preflight(ctx);
|
|
1859
|
+
// Persist the toolset into ~/.forge/ before linking anything (#1943): when
|
|
1860
|
+
// not skipped (git-clone installs are exempt), reassign ctx.forgeHome so
|
|
1861
|
+
// every symlink/hook path forge() creates below originates from the
|
|
1862
|
+
// stable copy, not the ephemeral npm/npx/git source. Stashed on ctx so
|
|
1863
|
+
// forge()'s reporting block can surface the outcome (see isEphemeralCachePath
|
|
1864
|
+
// advisory extension above).
|
|
1865
|
+
const persistResult = await persistHome(ctx);
|
|
1866
|
+
ctx.persistHomeResult = persistResult;
|
|
1867
|
+
if (!persistResult.skipped) {
|
|
1868
|
+
ctx.forgeHome = persistResult.forgeHome;
|
|
1869
|
+
}
|
|
971
1870
|
const forged = await forge(ctx);
|
|
972
1871
|
const { draft, description } = await read(ctx);
|
|
973
1872
|
const reviewed = await review(ctx, draft, description);
|
|
974
1873
|
const connected = await connect(ctx);
|
|
975
|
-
celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus });
|
|
1874
|
+
celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus, scriptsResult: forged.scriptsResult });
|
|
1875
|
+
await writeInstallReceipt(ctx, { forged });
|
|
1876
|
+
if (!reviewed.aborted) await maybeOfferDemo(ctx);
|
|
976
1877
|
return reviewed.aborted ? 1 : 0;
|
|
977
1878
|
} finally {
|
|
978
1879
|
process.removeListener("SIGINT", onSigint);
|