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/forgedock.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "fs";
|
|
16
16
|
import { execSync, execFileSync } from "child_process";
|
|
17
17
|
import { homedir } from "os";
|
|
18
|
+
import https from "https";
|
|
18
19
|
import {
|
|
19
20
|
makeCtx,
|
|
20
21
|
runJourney,
|
|
@@ -22,11 +23,16 @@ import {
|
|
|
22
23
|
read,
|
|
23
24
|
review,
|
|
24
25
|
celebrate,
|
|
26
|
+
maybeOfferDemo,
|
|
25
27
|
findMarkdownFiles,
|
|
26
28
|
parseInstallTier,
|
|
27
29
|
writeForgeYaml,
|
|
28
30
|
backupExisting,
|
|
29
31
|
manualLowConfidenceKeys,
|
|
32
|
+
isEphemeralCachePath,
|
|
33
|
+
writeInstallReceipt,
|
|
34
|
+
persistHome,
|
|
35
|
+
PIPELINE_SCRIPTS,
|
|
30
36
|
} from "./journey.mjs";
|
|
31
37
|
import {
|
|
32
38
|
removeSessionStartHook,
|
|
@@ -41,13 +47,17 @@ import {
|
|
|
41
47
|
detectClaudeVersion,
|
|
42
48
|
loadBreakpoints,
|
|
43
49
|
hasFeature,
|
|
50
|
+
getPersistedHomeState,
|
|
51
|
+
setPersistedHomeState,
|
|
44
52
|
} from "./registry.mjs";
|
|
45
53
|
import { renderMark, ember } from "./cinema.mjs";
|
|
46
54
|
import {
|
|
47
55
|
renderLogo,
|
|
56
|
+
getLogoTagline,
|
|
48
57
|
box,
|
|
49
58
|
table,
|
|
50
59
|
input,
|
|
60
|
+
confirm,
|
|
51
61
|
dim,
|
|
52
62
|
green,
|
|
53
63
|
yellow,
|
|
@@ -61,6 +71,15 @@ import {
|
|
|
61
71
|
CYAN,
|
|
62
72
|
} from "./tui.mjs";
|
|
63
73
|
import { buildMinimalForgeYaml } from "./init-detect.mjs";
|
|
74
|
+
import {
|
|
75
|
+
parseNameStatusDiff,
|
|
76
|
+
classifyCommandChanges,
|
|
77
|
+
countBreakingCommits,
|
|
78
|
+
parseGitHubOwnerRepo,
|
|
79
|
+
classifyConventionalCommitLines,
|
|
80
|
+
formatUpdateChangelogSummary,
|
|
81
|
+
formatVersionAvailableSummary,
|
|
82
|
+
} from "./forge-utils.mjs";
|
|
64
83
|
|
|
65
84
|
const __filename = fileURLToPath(import.meta.url);
|
|
66
85
|
const __dirname = dirname(__filename);
|
|
@@ -177,22 +196,17 @@ function detectInstallPaths() {
|
|
|
177
196
|
|
|
178
197
|
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
179
198
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const PIPELINE_SCRIPTS = new Set([
|
|
192
|
-
"classify-lane.sh",
|
|
193
|
-
"transition-label.sh",
|
|
194
|
-
"validate-pr-target.sh",
|
|
195
|
-
]);
|
|
199
|
+
// PIPELINE_SCRIPTS (the allowlist of universal pipeline-agent scripts linked
|
|
200
|
+
// to ~/.claude/scripts/) is defined in journey.mjs — forge()'s
|
|
201
|
+
// linkPipelineScripts() step is what writes them there (forge#1885,
|
|
202
|
+
// restoring the linkScripts() step dropped from the legacy install()/
|
|
203
|
+
// update() flow). Imported here so uninstall() removes exactly what forge()
|
|
204
|
+
// installs, off a single source of truth.
|
|
205
|
+
//
|
|
206
|
+
// Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
|
|
207
|
+
// covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
|
|
208
|
+
// review-pr.md and quality-gate.md and should not pollute the user's Claude
|
|
209
|
+
// scripts namespace (forge#715).
|
|
196
210
|
|
|
197
211
|
// Journey flags are stripped from positionals and fed to makeCtx via ctx().
|
|
198
212
|
// --minimal is init-only. Subcommands with their own flag parsing (run,
|
|
@@ -390,9 +404,197 @@ function getVersion() {
|
|
|
390
404
|
}
|
|
391
405
|
}
|
|
392
406
|
|
|
393
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Compare two dotted version strings component-wise (numeric, not
|
|
409
|
+
* lexicographic — "1.9.0" must compare as older than "1.10.0").
|
|
410
|
+
* Each component's leading numeric prefix is used (e.g. a prerelease-suffixed
|
|
411
|
+
* component like "3-beta" compares as 3, not 0 — parseInt reads the leading
|
|
412
|
+
* digits and stops at the first non-digit character, unlike Number() which
|
|
413
|
+
* would reject the whole component as NaN). Components with no leading
|
|
414
|
+
* numeric prefix, or missing components, are treated as 0.
|
|
415
|
+
*
|
|
416
|
+
* Note: `parseInt(n, 10)` is used deliberately, not `Number(n)`. This means
|
|
417
|
+
* component parsing only targets plain decimal digit runs — it does not
|
|
418
|
+
* interpret hex (`"0x10"`) or exponential (`"1e3"`) numeric-literal forms
|
|
419
|
+
* the way `Number()` would. That's intentional: real semver/npm version
|
|
420
|
+
* segments are never hex- or exponential-shaped, and swapping back to
|
|
421
|
+
* `Number()` to "fix" that mismatch would reintroduce the prerelease-suffix
|
|
422
|
+
* coercion bug this function was fixed for (see the note above).
|
|
423
|
+
*
|
|
424
|
+
* @param {string} a
|
|
425
|
+
* @param {string} b
|
|
426
|
+
* @returns {number} negative if a < b, positive if a > b, 0 if equal
|
|
427
|
+
*/
|
|
428
|
+
function compareVersions(a, b) {
|
|
429
|
+
const pa = String(a)
|
|
430
|
+
.split(".")
|
|
431
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
432
|
+
const pb = String(b)
|
|
433
|
+
.split(".")
|
|
434
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
435
|
+
const len = Math.max(pa.length, pb.length);
|
|
436
|
+
for (let i = 0; i < len; i++) {
|
|
437
|
+
const na = pa[i] || 0;
|
|
438
|
+
const nb = pb[i] || 0;
|
|
439
|
+
if (na !== nb) return na - nb;
|
|
440
|
+
}
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Fetch the latest published version of the `forgedock` package straight
|
|
446
|
+
* from the npm registry over HTTPS.
|
|
447
|
+
*
|
|
448
|
+
* Deliberately does NOT shell out to `npm view` — on some Windows setups
|
|
449
|
+
* `execSync("npm view ...")` reliably hangs until the timeout kills it
|
|
450
|
+
* (npm.cmd spawned via cmd.exe), even though the same command runs
|
|
451
|
+
* instantly from an interactive shell. A direct registry request has no
|
|
452
|
+
* such dependency on npm being on PATH or well-behaved under a subshell,
|
|
453
|
+
* and matches what npm's own update-notifier does internally.
|
|
454
|
+
*
|
|
455
|
+
* Resolves to "" (never rejects) on any failure — offline, DNS failure,
|
|
456
|
+
* non-200 response, oversized response body, malformed JSON, or timeout —
|
|
457
|
+
* so callers can treat "" as "could not determine" without their own
|
|
458
|
+
* try/catch.
|
|
459
|
+
*
|
|
460
|
+
* @returns {Promise<string>} latest version string, or "" if unknown
|
|
461
|
+
*/
|
|
462
|
+
function fetchLatestVersion() {
|
|
463
|
+
return new Promise((resolve) => {
|
|
464
|
+
let settled = false;
|
|
465
|
+
const done = (value) => {
|
|
466
|
+
if (settled) return;
|
|
467
|
+
settled = true;
|
|
468
|
+
resolve(value);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Real payload is ~1-2 KB; this is generous headroom against an
|
|
472
|
+
// unbounded response body buffering before JSON.parse (issue #1931).
|
|
473
|
+
const MAX_RESPONSE_BYTES = 65536;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const req = https.get(
|
|
477
|
+
"https://registry.npmjs.org/forgedock/latest",
|
|
478
|
+
{ timeout: 5000, headers: { Accept: "application/json" } },
|
|
479
|
+
(res) => {
|
|
480
|
+
if (res.statusCode !== 200) {
|
|
481
|
+
res.resume();
|
|
482
|
+
return done("");
|
|
483
|
+
}
|
|
484
|
+
let data = "";
|
|
485
|
+
res.on("data", (chunk) => {
|
|
486
|
+
if (settled) return;
|
|
487
|
+
data += chunk;
|
|
488
|
+
if (data.length > MAX_RESPONSE_BYTES) {
|
|
489
|
+
res.destroy();
|
|
490
|
+
done("");
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
res.on("end", () => {
|
|
494
|
+
try {
|
|
495
|
+
const json = JSON.parse(data);
|
|
496
|
+
done(typeof json.version === "string" ? json.version : "");
|
|
497
|
+
} catch {
|
|
498
|
+
done("");
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
res.on("error", () => done(""));
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
req.on("timeout", () => {
|
|
505
|
+
req.destroy();
|
|
506
|
+
done("");
|
|
507
|
+
});
|
|
508
|
+
req.on("error", () => done(""));
|
|
509
|
+
} catch {
|
|
510
|
+
done("");
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Fetch the release notes ("What's Changed" body + html_url) for a tagged
|
|
517
|
+
* GitHub release, used to build the diff-aware changelog summary
|
|
518
|
+
* (forge#1947) when a newer version is available in npm/npx mode.
|
|
519
|
+
*
|
|
520
|
+
* Mirrors fetchLatestVersion()'s defensive contract: resolves to `null`
|
|
521
|
+
* (never rejects) on any failure — offline, DNS failure, non-200 response
|
|
522
|
+
* (including 404 for an unpublished tag or 403 for rate-limiting), oversized
|
|
523
|
+
* response body, malformed JSON, or timeout. Callers treat `null` as
|
|
524
|
+
* "changelog unavailable" and skip the summary silently — this must never
|
|
525
|
+
* block or fail the update itself.
|
|
526
|
+
*
|
|
527
|
+
* @param {string} owner
|
|
528
|
+
* @param {string} repo
|
|
529
|
+
* @param {string} tag - Release tag, e.g. "v1.1.9".
|
|
530
|
+
* @returns {Promise<{body: string, html_url: string}|null>}
|
|
531
|
+
*/
|
|
532
|
+
function fetchGitHubReleaseNotes(owner, repo, tag) {
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
let settled = false;
|
|
535
|
+
const done = (value) => {
|
|
536
|
+
if (settled) return;
|
|
537
|
+
settled = true;
|
|
538
|
+
resolve(value);
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Release bodies can be a few KB for a busy release — generous headroom
|
|
542
|
+
// against an unbounded response body buffering before JSON.parse.
|
|
543
|
+
const MAX_RESPONSE_BYTES = 262144;
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const req = https.get(
|
|
547
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
|
|
548
|
+
{
|
|
549
|
+
timeout: 5000,
|
|
550
|
+
headers: {
|
|
551
|
+
Accept: "application/vnd.github+json",
|
|
552
|
+
"User-Agent": "forgedock-cli",
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
(res) => {
|
|
556
|
+
if (res.statusCode !== 200) {
|
|
557
|
+
res.resume();
|
|
558
|
+
return done(null);
|
|
559
|
+
}
|
|
560
|
+
let data = "";
|
|
561
|
+
res.on("data", (chunk) => {
|
|
562
|
+
if (settled) return;
|
|
563
|
+
data += chunk;
|
|
564
|
+
if (data.length > MAX_RESPONSE_BYTES) {
|
|
565
|
+
res.destroy();
|
|
566
|
+
done(null);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
res.on("end", () => {
|
|
570
|
+
try {
|
|
571
|
+
const json = JSON.parse(data);
|
|
572
|
+
if (typeof json.body !== "string") return done(null);
|
|
573
|
+
done({
|
|
574
|
+
body: json.body,
|
|
575
|
+
html_url: typeof json.html_url === "string" ? json.html_url : "",
|
|
576
|
+
});
|
|
577
|
+
} catch {
|
|
578
|
+
done(null);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
res.on("error", () => done(null));
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
req.on("timeout", () => {
|
|
585
|
+
req.destroy();
|
|
586
|
+
done(null);
|
|
587
|
+
});
|
|
588
|
+
req.on("error", () => done(null));
|
|
589
|
+
} catch {
|
|
590
|
+
done(null);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function splash(context = "") {
|
|
394
596
|
const version = getVersion();
|
|
395
|
-
process.stderr.write(renderLogo({ version }) + "\n");
|
|
597
|
+
process.stderr.write(renderLogo({ version, context }) + "\n");
|
|
396
598
|
}
|
|
397
599
|
|
|
398
600
|
// ---------------------------------------------------------------------------
|
|
@@ -617,19 +819,125 @@ function ctx() {
|
|
|
617
819
|
return c;
|
|
618
820
|
}
|
|
619
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Extract `project.owner`/`project.repo` and `branches.staging` from a
|
|
824
|
+
* forge.yaml's raw text, using the same tolerant, no-YAML-parser regex style
|
|
825
|
+
* as resolveLabelsRepo()/doctor's Check 4 (quoted or unquoted scalar, ignores
|
|
826
|
+
* inline comments). Deliberately separate from resolveLabelsRepo() — that
|
|
827
|
+
* function also consults a CLI `--repo` flag, which doesn't apply here.
|
|
828
|
+
* @param {string} raw - forge.yaml file contents
|
|
829
|
+
* @returns {{repo: string|null, staging: string|null}}
|
|
830
|
+
*/
|
|
831
|
+
function extractRepoAndStaging(raw) {
|
|
832
|
+
const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
|
|
833
|
+
const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
|
|
834
|
+
const stagingMatch = raw.match(/^\s*staging:\s*["']?([^\s"'#]+)["']?/m);
|
|
835
|
+
return {
|
|
836
|
+
repo: ownerMatch && repoMatch ? `${ownerMatch[1]}/${repoMatch[1]}` : null,
|
|
837
|
+
staging: stagingMatch ? stagingMatch[1] : null,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Best-effort re-entry dashboard data (issue #1945): staging PR count and
|
|
843
|
+
* engine in-flight/stalled counts. Every source is independently try/caught —
|
|
844
|
+
* one failing (no gh auth, no forge.yaml, empty ~/.forge/runs) degrades that
|
|
845
|
+
* row to "unknown"/"none" rather than blocking the others or crashing the
|
|
846
|
+
* whole status screen. `engine-cli.mjs` is dynamically imported (matching the
|
|
847
|
+
* existing `run-issue`/`resume-stalled` call sites in this file) so `status`
|
|
848
|
+
* doesn't pay the engine module's load cost unless it's actually reachable.
|
|
849
|
+
* @param {string} cwd
|
|
850
|
+
* @returns {Promise<{repo: string|null, staging: string|null,
|
|
851
|
+
* prCount: number|null, engine: {total:number,inFlight:number,stalled:number}|null,
|
|
852
|
+
* lastRun: {issue:number,terminal:boolean,terminalReason:string|null}|null}>}
|
|
853
|
+
*/
|
|
854
|
+
async function gatherDashboardData(cwd) {
|
|
855
|
+
const result = { repo: null, staging: null, prCount: null, engine: null, lastRun: null };
|
|
856
|
+
|
|
857
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
858
|
+
if (existsSync(forgeYamlPath)) {
|
|
859
|
+
try {
|
|
860
|
+
const raw = readFileSync(forgeYamlPath, "utf-8");
|
|
861
|
+
const { repo, staging } = extractRepoAndStaging(raw);
|
|
862
|
+
result.repo = repo;
|
|
863
|
+
result.staging = staging;
|
|
864
|
+
} catch {
|
|
865
|
+
// leave repo/staging null — dashboard rows degrade below
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
let engineCli = null;
|
|
870
|
+
try {
|
|
871
|
+
engineCli = await import("./engine-cli.mjs");
|
|
872
|
+
} catch {
|
|
873
|
+
// engine module unavailable — engine/lastRun rows stay null
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (result.repo) {
|
|
877
|
+
try {
|
|
878
|
+
const args = ["pr", "list", "-R", result.repo, "--state", "open", "--json", "number"];
|
|
879
|
+
if (result.staging) args.push("--base", result.staging);
|
|
880
|
+
const out = execFileSync("gh", args, {
|
|
881
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
882
|
+
encoding: "utf-8",
|
|
883
|
+
timeout: 10000,
|
|
884
|
+
});
|
|
885
|
+
result.prCount = JSON.parse(out).length;
|
|
886
|
+
} catch {
|
|
887
|
+
// gh not authenticated, not installed, or timed out — leave prCount null ("unknown")
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (engineCli) {
|
|
892
|
+
try {
|
|
893
|
+
const io = engineCli.makeIo();
|
|
894
|
+
result.engine = await engineCli.countEngineActivity(io, result.repo, Date.now());
|
|
895
|
+
} catch {
|
|
896
|
+
// leave engine null ("unknown")
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
result.lastRun = engineCli.lastLocalRun(engineCli.runDir());
|
|
900
|
+
} catch {
|
|
901
|
+
// leave lastRun null ("none")
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return result;
|
|
906
|
+
}
|
|
907
|
+
|
|
620
908
|
/** Compact status screen for configured/managed directories. */
|
|
621
|
-
function statusScreen(c) {
|
|
909
|
+
async function statusScreen(c) {
|
|
622
910
|
const state = resolveState(c.cwd);
|
|
623
911
|
const mark = renderMark("compact", c.mode);
|
|
624
912
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
625
913
|
c.stdout.write("\n" + mark[0] + "\n");
|
|
626
914
|
c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
|
|
627
|
-
c.stdout.write(mark[2] + "\n" + mark[3] + "\n
|
|
915
|
+
c.stdout.write(mark[2] + "\n" + mark[3] + "\n");
|
|
916
|
+
c.stdout.write(" " + dim(getLogoTagline("status")) + "\n\n");
|
|
628
917
|
const configured = existsSync(join(c.cwd, "forge.yaml"));
|
|
629
918
|
c.stdout.write(` directory ${state}\n`);
|
|
630
919
|
c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
|
|
631
920
|
const { targetDir: statusTargetDir } = detectInstallPaths();
|
|
632
921
|
c.stdout.write(` commands ${existsSync(statusTargetDir) ? "installed at " + statusTargetDir : "not installed"}\n`);
|
|
922
|
+
|
|
923
|
+
// Re-entry mini-dashboard (#1945) — only meaningful once the repo is
|
|
924
|
+
// actually configured; an unconfigured/unmanaged directory has nothing to
|
|
925
|
+
// report yet and would otherwise render a wall of "unknown" rows.
|
|
926
|
+
if (configured && (state === "managed-active")) {
|
|
927
|
+
const data = await gatherDashboardData(c.cwd);
|
|
928
|
+
c.stdout.write("\n");
|
|
929
|
+
c.stdout.write(
|
|
930
|
+
` staging PRs ${data.prCount === null ? dim("unknown") : `${data.prCount} open${data.staging ? " → " + data.staging : ""}`}\n`,
|
|
931
|
+
);
|
|
932
|
+
c.stdout.write(
|
|
933
|
+
` engine ${data.engine === null ? dim("unknown") : (data.engine.total === 0 ? "none in-flight" : `${data.engine.inFlight} in-flight, ${data.engine.stalled} stalled`)}\n`,
|
|
934
|
+
);
|
|
935
|
+
c.stdout.write(` bot token ${dim("not available — see docs/CONFIG.md (GitHub App Install)")}\n`);
|
|
936
|
+
c.stdout.write(
|
|
937
|
+
` last run ${data.lastRun === null ? dim("none") : `#${data.lastRun.issue} — ${data.lastRun.terminal ? (data.lastRun.terminalReason || "done") : "in progress"}`}\n`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
633
941
|
if (state === "managed-optedout") {
|
|
634
942
|
c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
|
|
635
943
|
} else if (state === "unmanaged") {
|
|
@@ -644,18 +952,44 @@ function statusScreen(c) {
|
|
|
644
952
|
|
|
645
953
|
async function initFlow(c) {
|
|
646
954
|
const outputPath = join(c.cwd, "forge.yaml");
|
|
647
|
-
|
|
955
|
+
const hasExisting = existsSync(outputPath);
|
|
956
|
+
if (hasExisting && process.stdin.isTTY !== true) {
|
|
648
957
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
649
958
|
c.stdout.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
|
|
650
959
|
c.stdout.write(" " + dim("Run interactively (or delete forge.yaml) to regenerate.") + "\n");
|
|
651
960
|
return 1;
|
|
652
961
|
}
|
|
653
962
|
|
|
963
|
+
// Restore the #578 overwrite-confirmation gate for the --minimal/--manual
|
|
964
|
+
// escape hatches (forge#1850): these branches previously went straight to
|
|
965
|
+
// backupExisting() + write with ZERO on-screen warning in TTY mode — worse
|
|
966
|
+
// than the default review() flow below, which at least shows an
|
|
967
|
+
// "Overwrite Mode" banner. The default flow's own gate lives in
|
|
968
|
+
// journey.mjs review() (ctx.confirmFn).
|
|
969
|
+
if (hasExisting && (flags.includes("--minimal") || flags.includes("--manual"))) {
|
|
970
|
+
const confirmed = await confirm(
|
|
971
|
+
"forge.yaml already exists. Overwrite it? A backup will be created.",
|
|
972
|
+
false,
|
|
973
|
+
);
|
|
974
|
+
if (!confirmed) {
|
|
975
|
+
c.stdout.write("\n Overwrite cancelled — forge.yaml left untouched.\n");
|
|
976
|
+
return 1;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
654
980
|
if (flags.includes("--minimal")) {
|
|
655
981
|
// Staging semantics preserved (#1148): detection → minimal template with
|
|
656
982
|
// only the three required sections. No review screen — this is the
|
|
657
983
|
// power-user escape hatch for a ~20-line forge.yaml.
|
|
658
984
|
const { draft, description } = await read(c);
|
|
985
|
+
// Hard-fail on unresolved identity placeholders when overwriting an
|
|
986
|
+
// existing config (forge#1850 spec item 4) — never destroy a working
|
|
987
|
+
// forge.yaml in favor of one that can't address a repo.
|
|
988
|
+
if (hasExisting && (draft.project.owner.value === "your-github-org" || draft.project.repo.value === "your-repo-name")) {
|
|
989
|
+
c.stdout.write("\n Could not determine the GitHub owner/repo for this project.\n");
|
|
990
|
+
c.stdout.write(" Run this inside the target git repository, or use interactive init (npx forgedock init) to edit those fields.\n");
|
|
991
|
+
return 1;
|
|
992
|
+
}
|
|
659
993
|
const backup = backupExisting(outputPath);
|
|
660
994
|
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
661
995
|
const content = buildMinimalForgeYaml({
|
|
@@ -670,6 +1004,7 @@ async function initFlow(c) {
|
|
|
670
1004
|
});
|
|
671
1005
|
atomicWriteFile(outputPath, content);
|
|
672
1006
|
celebrate(c, { written: true, todoCount: 0, isMinimal: true });
|
|
1007
|
+
await maybeOfferDemo(c);
|
|
673
1008
|
return 0;
|
|
674
1009
|
}
|
|
675
1010
|
|
|
@@ -686,16 +1021,25 @@ async function initFlow(c) {
|
|
|
686
1021
|
defaultBranch: await input("Default branch", draft.branches.default.value),
|
|
687
1022
|
stagingBranch: await input("Staging branch", draft.branches.staging.value),
|
|
688
1023
|
};
|
|
1024
|
+
// Checked on the FINAL accepted values (post-prompt), not the draft —
|
|
1025
|
+
// the user may have typed over a placeholder default.
|
|
1026
|
+
if (hasExisting && (v.owner === "your-github-org" || v.repo === "your-repo-name")) {
|
|
1027
|
+
c.stdout.write("\n Could not determine the GitHub owner/repo for this project.\n");
|
|
1028
|
+
c.stdout.write(" Enter real values, or run this inside the target git repository.\n");
|
|
1029
|
+
return 1;
|
|
1030
|
+
}
|
|
689
1031
|
const backup = backupExisting(outputPath);
|
|
690
1032
|
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
691
1033
|
const lowKeys = manualLowConfidenceKeys(draft, description, v);
|
|
692
1034
|
const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
|
|
693
1035
|
celebrate(c, { written: true, todoCount });
|
|
1036
|
+
await maybeOfferDemo(c);
|
|
694
1037
|
return 0;
|
|
695
1038
|
}
|
|
696
1039
|
const { draft, description } = await read(c);
|
|
697
1040
|
const reviewed = await review(c, draft, description);
|
|
698
1041
|
celebrate(c, reviewed);
|
|
1042
|
+
if (!reviewed.aborted) await maybeOfferDemo(c);
|
|
699
1043
|
return reviewed.aborted ? 1 : 0;
|
|
700
1044
|
}
|
|
701
1045
|
|
|
@@ -706,6 +1050,7 @@ async function uninstall() {
|
|
|
706
1050
|
|
|
707
1051
|
const { targetDir, scriptsTargetDir, manifestPath } = detectInstallPaths();
|
|
708
1052
|
console.log(` Mode: global (~/.claude)`);
|
|
1053
|
+
console.log(` ${dim(getLogoTagline("uninstall"))}`);
|
|
709
1054
|
console.log("");
|
|
710
1055
|
|
|
711
1056
|
const files = await findMarkdownFiles(COMMANDS_DIR);
|
|
@@ -965,19 +1310,162 @@ async function uninstall() {
|
|
|
965
1310
|
// symlinks or hook registration got out of sync.
|
|
966
1311
|
async function relinkAndHint() {
|
|
967
1312
|
const c = ctx();
|
|
968
|
-
await forge(c);
|
|
1313
|
+
const forged = await forge(c);
|
|
1314
|
+
// Refresh the install receipt (#1946) — relinkAndHint() is called from
|
|
1315
|
+
// BOTH update() branches (the git-clone fast-forward path and the npm
|
|
1316
|
+
// version-check path), so wiring it here covers "refreshed after every
|
|
1317
|
+
// successful update" without duplicating the call at each branch. Note:
|
|
1318
|
+
// this is NOT reached by install's already-managed-active short-circuit
|
|
1319
|
+
// (that path calls statusScreen(), never relinkAndHint()) — see the
|
|
1320
|
+
// writeInstallReceipt() JSDoc in journey.mjs for the full picture.
|
|
1321
|
+
await writeInstallReceipt(c, { forged });
|
|
969
1322
|
if (!existsSync(join(c.cwd, "forge.yaml"))) {
|
|
970
1323
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
971
1324
|
c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
|
|
972
1325
|
}
|
|
973
1326
|
}
|
|
974
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* Print a condensed, diff-aware changelog summary after a successful
|
|
1330
|
+
* git-clone-mode fast-forward update (forge#1947).
|
|
1331
|
+
*
|
|
1332
|
+
* Sourced entirely from local git history — no network call needed here,
|
|
1333
|
+
* since the git-clone install already has the full commit range available.
|
|
1334
|
+
* Reads old/new package.json versions from the before/after commits, diffs
|
|
1335
|
+
* `commands/` + `bin/engine/` for added/updated/removed files, scans commit
|
|
1336
|
+
* subjects for breaking-change markers, and derives a compare-URL from the
|
|
1337
|
+
* `origin` remote (omitted if the remote isn't a recognizable GitHub URL).
|
|
1338
|
+
*
|
|
1339
|
+
* Best-effort and read-only: any failure (git command error, JSON parse
|
|
1340
|
+
* error, unparseable remote) is swallowed silently so it can never block or
|
|
1341
|
+
* fail the update itself — the caller has already printed "Updated to
|
|
1342
|
+
* latest." by the time this runs.
|
|
1343
|
+
*
|
|
1344
|
+
* All git subprocess calls use execFileSync with an argument array (never a
|
|
1345
|
+
* template-literal string passed to execSync) — see forge#1703/forge#413:
|
|
1346
|
+
* interpolating a variable into an execSync string spawns `/bin/sh -c` and
|
|
1347
|
+
* shell-parses it.
|
|
1348
|
+
*
|
|
1349
|
+
* @param {string} before - HEAD SHA before the merge.
|
|
1350
|
+
* @param {string} after - HEAD SHA after the merge.
|
|
1351
|
+
*/
|
|
1352
|
+
function printGitCloneChangelogSummary(before, after) {
|
|
1353
|
+
try {
|
|
1354
|
+
let fromVersion = "";
|
|
1355
|
+
try {
|
|
1356
|
+
const beforePkgRaw = execFileSync("git", ["show", `${before}:package.json`], {
|
|
1357
|
+
cwd: FORGE_HOME,
|
|
1358
|
+
encoding: "utf-8",
|
|
1359
|
+
});
|
|
1360
|
+
fromVersion = JSON.parse(beforePkgRaw).version || "";
|
|
1361
|
+
} catch {
|
|
1362
|
+
// package.json may not have existed at `before` (fresh install range)
|
|
1363
|
+
// or failed to parse — the summary still works without a from-version.
|
|
1364
|
+
}
|
|
1365
|
+
const toVersion = getVersion();
|
|
1366
|
+
|
|
1367
|
+
const diffOutput = execFileSync(
|
|
1368
|
+
"git",
|
|
1369
|
+
["diff", "--name-status", before, after, "--", "commands/", "bin/engine/"],
|
|
1370
|
+
{ cwd: FORGE_HOME, encoding: "utf-8" },
|
|
1371
|
+
);
|
|
1372
|
+
const { commandsAdded, commandsUpdated, commandsRemoved, engineChanged } =
|
|
1373
|
+
classifyCommandChanges(parseNameStatusDiff(diffOutput));
|
|
1374
|
+
|
|
1375
|
+
const subjectsOutput = execFileSync(
|
|
1376
|
+
"git",
|
|
1377
|
+
["log", "--pretty=%s", `${before}..${after}`],
|
|
1378
|
+
{ cwd: FORGE_HOME, encoding: "utf-8" },
|
|
1379
|
+
);
|
|
1380
|
+
const breakingCount = countBreakingCommits(subjectsOutput.split(/\r?\n/));
|
|
1381
|
+
|
|
1382
|
+
let compareUrl;
|
|
1383
|
+
try {
|
|
1384
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
1385
|
+
cwd: FORGE_HOME,
|
|
1386
|
+
encoding: "utf-8",
|
|
1387
|
+
}).trim();
|
|
1388
|
+
const ownerRepo = parseGitHubOwnerRepo(remoteUrl);
|
|
1389
|
+
if (ownerRepo && fromVersion && toVersion) {
|
|
1390
|
+
compareUrl = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/compare/v${fromVersion}...v${toVersion}`;
|
|
1391
|
+
}
|
|
1392
|
+
} catch {
|
|
1393
|
+
// No `origin` remote, or an unparseable/non-GitHub URL — the summary
|
|
1394
|
+
// still prints, just without a compare link.
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const summary = formatUpdateChangelogSummary({
|
|
1398
|
+
fromVersion,
|
|
1399
|
+
toVersion,
|
|
1400
|
+
commandsAdded,
|
|
1401
|
+
commandsUpdated,
|
|
1402
|
+
commandsRemoved,
|
|
1403
|
+
engineChanged,
|
|
1404
|
+
breakingCount,
|
|
1405
|
+
compareUrl,
|
|
1406
|
+
});
|
|
1407
|
+
for (const line of summary.split("\n")) {
|
|
1408
|
+
console.log(` ${dim(line)}`);
|
|
1409
|
+
}
|
|
1410
|
+
} catch {
|
|
1411
|
+
// Changelog summary is best-effort only — never block or fail the
|
|
1412
|
+
// update itself over a diff/parse error.
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Best-effort fetch + print of a diff-aware changelog summary when a newer
|
|
1418
|
+
* version is available in npm/npx mode (forge#1947).
|
|
1419
|
+
*
|
|
1420
|
+
* npm/npx mode never runs the actual update itself (the user is told to run
|
|
1421
|
+
* `npx forgedock@latest`), so there is no local before/after diff to source
|
|
1422
|
+
* from. Instead, this fetches the newest published GitHub release's notes
|
|
1423
|
+
* (hardcoded canonical repo — npm-installed forgedock always originates
|
|
1424
|
+
* there, there is no local git remote to inspect) and condenses its
|
|
1425
|
+
* auto-generated "What's Changed" bullet list into conventional-commit-type
|
|
1426
|
+
* counts.
|
|
1427
|
+
*
|
|
1428
|
+
* Any failure (network down, rate-limited, unpublished tag) resolves to
|
|
1429
|
+
* `null` from fetchGitHubReleaseNotes() and is silently skipped — the
|
|
1430
|
+
* existing "New version available" message has already been printed by the
|
|
1431
|
+
* caller regardless.
|
|
1432
|
+
*
|
|
1433
|
+
* @param {string} localVersion
|
|
1434
|
+
* @param {string} latestVersion
|
|
1435
|
+
*/
|
|
1436
|
+
async function printVersionAvailableChangelog(localVersion, latestVersion) {
|
|
1437
|
+
try {
|
|
1438
|
+
const notes = await fetchGitHubReleaseNotes(
|
|
1439
|
+
"RapierCraftStudios",
|
|
1440
|
+
"ForgeDock",
|
|
1441
|
+
`v${latestVersion}`,
|
|
1442
|
+
);
|
|
1443
|
+
if (!notes) return;
|
|
1444
|
+
|
|
1445
|
+
const { counts, breakingCount } = classifyConventionalCommitLines(notes.body);
|
|
1446
|
+
const summary = formatVersionAvailableSummary({
|
|
1447
|
+
currentVersion: localVersion,
|
|
1448
|
+
latestVersion,
|
|
1449
|
+
typeCounts: counts,
|
|
1450
|
+
breakingCount,
|
|
1451
|
+
releaseUrl: notes.html_url,
|
|
1452
|
+
});
|
|
1453
|
+
for (const line of summary.split("\n")) {
|
|
1454
|
+
console.log(` ${dim(line)}`);
|
|
1455
|
+
}
|
|
1456
|
+
} catch {
|
|
1457
|
+
// Best-effort only — a failure here must never block the update advice
|
|
1458
|
+
// already printed by the caller.
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
975
1462
|
async function update() {
|
|
976
1463
|
console.log("");
|
|
977
1464
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
978
1465
|
console.log("");
|
|
979
1466
|
|
|
980
1467
|
console.log(` Mode: global (~/.claude)`);
|
|
1468
|
+
console.log(` ${dim(getLogoTagline("update"))}`);
|
|
981
1469
|
|
|
982
1470
|
// Check if installed via npm (no .git directory) or via git clone
|
|
983
1471
|
const gitDir = join(FORGE_HOME, ".git");
|
|
@@ -1023,6 +1511,7 @@ async function update() {
|
|
|
1023
1511
|
console.log(` Already up to date.`);
|
|
1024
1512
|
} else {
|
|
1025
1513
|
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
1514
|
+
printGitCloneChangelogSummary(before, after);
|
|
1026
1515
|
}
|
|
1027
1516
|
await relinkAndHint();
|
|
1028
1517
|
|
|
@@ -1045,9 +1534,57 @@ async function update() {
|
|
|
1045
1534
|
);
|
|
1046
1535
|
}
|
|
1047
1536
|
} else {
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1537
|
+
// npm/npx install (no .git dir in FORGE_HOME) — most commonly the npx
|
|
1538
|
+
// cache path (`npx forgedock`), which was never globally installed, so
|
|
1539
|
+
// "npm update -g forgedock" is a no-op. Check the actual published
|
|
1540
|
+
// version instead and give advice that reflects the real install model.
|
|
1541
|
+
const localVersion = getVersion();
|
|
1542
|
+
const latestVersion = await fetchLatestVersion();
|
|
1543
|
+
|
|
1544
|
+
if (!latestVersion) {
|
|
1545
|
+
console.log(
|
|
1546
|
+
` ${YELLOW}Could not check npm for the latest version.${RESET} To force a refresh: ${CYAN}npx forgedock@latest${RESET}`,
|
|
1547
|
+
);
|
|
1548
|
+
} else if (!localVersion) {
|
|
1549
|
+
console.log(
|
|
1550
|
+
` Latest published version is ${CYAN}v${latestVersion}${RESET}. To update: ${CYAN}npx forgedock@latest${RESET}`,
|
|
1551
|
+
);
|
|
1552
|
+
} else if (compareVersions(latestVersion, localVersion) > 0) {
|
|
1553
|
+
console.log(
|
|
1554
|
+
` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}).`,
|
|
1555
|
+
);
|
|
1556
|
+
console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
|
|
1557
|
+
await printVersionAvailableChangelog(localVersion, latestVersion);
|
|
1558
|
+
} else {
|
|
1559
|
+
console.log(` Already up to date (v${localVersion}).`);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Refresh the persisted ~/.forge/ copy from the currently-resolved
|
|
1563
|
+
// package (forge#1943). The version-check above is advisory only — it
|
|
1564
|
+
// tells the user to re-run npx forgedock@latest but never touched disk.
|
|
1565
|
+
// This actually copies whatever payload FORGE_HOME resolves to right now
|
|
1566
|
+
// (the just-fetched npx package on `npx forgedock@latest update`, or
|
|
1567
|
+
// whatever was already resolved on a plain `npx forgedock update`) into
|
|
1568
|
+
// ~/.forge/, so the persisted copy doesn't go stale between full
|
|
1569
|
+
// install/init runs. Skipped as a no-op for git-clone installs (handled
|
|
1570
|
+
// by the `if (existsSync(gitDir))` branch above — this code path only
|
|
1571
|
+
// runs for npm/npx installs). Fail-open: any error here must never block
|
|
1572
|
+
// relinkAndHint() below.
|
|
1573
|
+
try {
|
|
1574
|
+
const persisted = await persistHome(ctx());
|
|
1575
|
+
if (!persisted.skipped) {
|
|
1576
|
+
await setPersistedHomeState({
|
|
1577
|
+
path: persisted.forgeHome,
|
|
1578
|
+
version: persisted.version,
|
|
1579
|
+
});
|
|
1580
|
+
if (persisted.migrated) {
|
|
1581
|
+
console.log(` ${GREEN}Refreshed ~/.forge/${RESET} (v${persisted.version || "unknown"}).`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
} catch {
|
|
1585
|
+
// Best-effort — persisted-home refresh must never block the update.
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1051
1588
|
await relinkAndHint();
|
|
1052
1589
|
}
|
|
1053
1590
|
console.log("");
|
|
@@ -1202,10 +1739,23 @@ async function labelsSetup(repo) {
|
|
|
1202
1739
|
* 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
|
|
1203
1740
|
* 10. yq installed (hard dependency for forge.yaml parsing)
|
|
1204
1741
|
* 11. Claude Code installed and version compatible (advisory warn if not on PATH)
|
|
1742
|
+
* 12. GitHub App / bot token status — informational only; reports that
|
|
1743
|
+
* pipeline gh calls use the active `gh auth` context (personal token
|
|
1744
|
+
* unless the operator has manually configured a bot token). Installing
|
|
1745
|
+
* the GitHub App alone does not create a bot token (see forge#1890).
|
|
1746
|
+
*
|
|
1747
|
+
* `--fix` (fix=true): auto-applies remediation for checks with a safe,
|
|
1748
|
+
* deterministic, no-input-needed fix — Command symlinks (1), SessionStart
|
|
1749
|
+
* hook registration (5), SessionStart hook script path integrity (5c),
|
|
1750
|
+
* CLAUDE.md legacy block (6), and GitHub workflow labels (7). Checks that
|
|
1751
|
+
* require user interaction (gh auth login, git identity, forge.yaml init,
|
|
1752
|
+
* yq/Claude Code installs, Playwright MCP registration) are always
|
|
1753
|
+
* report-only — `--fix` never touches them. See forge#1944.
|
|
1205
1754
|
*
|
|
1206
1755
|
* Returns 0 if all checks pass (warnings allowed), 1 if any hard check fails.
|
|
1756
|
+
* @param {boolean} [fix] - When true, auto-apply deterministic remediations.
|
|
1207
1757
|
*/
|
|
1208
|
-
async function doctor() {
|
|
1758
|
+
async function doctor(fix = false) {
|
|
1209
1759
|
console.log("");
|
|
1210
1760
|
console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
|
|
1211
1761
|
console.log("");
|
|
@@ -1249,71 +1799,179 @@ async function doctor() {
|
|
|
1249
1799
|
console.log(` Note: ${hint}`);
|
|
1250
1800
|
}
|
|
1251
1801
|
|
|
1802
|
+
/**
|
|
1803
|
+
* Print a "fixed" line — distinct from pass/fail/warn — for a check that
|
|
1804
|
+
* failed or warned on first detection but was successfully auto-remediated
|
|
1805
|
+
* during this run. Does not count toward failures/warnings.
|
|
1806
|
+
* @param {string} label
|
|
1807
|
+
* @param {string} [detail]
|
|
1808
|
+
*/
|
|
1809
|
+
let fixesApplied = 0;
|
|
1810
|
+
function fixed(label, detail) {
|
|
1811
|
+
fixesApplied++;
|
|
1812
|
+
const suffix = detail ? ` ${detail}` : "";
|
|
1813
|
+
console.log(` ${CYAN}↻${RESET} ${BOLD}${label}${RESET}${suffix}`);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const settingsPath = join(HOME, ".claude", "settings.json");
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Detect whether the registered SessionStart hook entry (if any) points at
|
|
1820
|
+
* a script path that no longer exists on disk — the same detection Check 5c
|
|
1821
|
+
* reports on. Shared with runInstallRepairOnce() below: forge()'s
|
|
1822
|
+
* installSessionStartHook() treats ANY existing ForgeDock-marked entry as
|
|
1823
|
+
* "already registered" regardless of whether its script path still
|
|
1824
|
+
* resolves, so a dangling entry must be explicitly cleared before forge()
|
|
1825
|
+
* will register a fresh one. (forge#1895)
|
|
1826
|
+
* @returns {{state: 'ok'|'no-entry'|'unparseable'|'dangling'|'error', path: (string|null), command?: string, ephemeral?: boolean, error: (string|null)}}
|
|
1827
|
+
*/
|
|
1828
|
+
function detectSessionStartHookPath() {
|
|
1829
|
+
try {
|
|
1830
|
+
const settings = readClaudeSettings();
|
|
1831
|
+
const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
|
|
1832
|
+
? settings.hooks.SessionStart
|
|
1833
|
+
: [];
|
|
1834
|
+
|
|
1835
|
+
let hookCommand = null;
|
|
1836
|
+
outer: for (const entry of sessionStartEntries) {
|
|
1837
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1838
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
1839
|
+
for (const h of hooks) {
|
|
1840
|
+
if (h && typeof h.command === "string" && isForgeSessionStartHook(h.command)) {
|
|
1841
|
+
hookCommand = h.command;
|
|
1842
|
+
break outer;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
if (!hookCommand) return { state: "no-entry", path: null, error: null };
|
|
1848
|
+
|
|
1849
|
+
const pathMatch = hookCommand.match(/node\s+"([^"]+)"/);
|
|
1850
|
+
const hookScriptPath = pathMatch ? pathMatch[1] : null;
|
|
1851
|
+
if (!hookScriptPath) {
|
|
1852
|
+
return { state: "unparseable", path: null, command: hookCommand, error: null };
|
|
1853
|
+
}
|
|
1854
|
+
if (existsSync(hookScriptPath)) {
|
|
1855
|
+
return { state: "ok", path: hookScriptPath, error: null };
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
state: "dangling",
|
|
1859
|
+
path: hookScriptPath,
|
|
1860
|
+
ephemeral: isEphemeralCachePath(hookScriptPath),
|
|
1861
|
+
error: null,
|
|
1862
|
+
};
|
|
1863
|
+
} catch (err) {
|
|
1864
|
+
return { state: "error", path: null, error: err.message };
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Shared repair primitive for Checks 1 (command symlinks), 5 (SessionStart
|
|
1870
|
+
* hook registration), and 5c (SessionStart hook script path integrity).
|
|
1871
|
+
* Memoized so it only runs once per `doctor --fix` invocation even though
|
|
1872
|
+
* more than one check can trigger it.
|
|
1873
|
+
*
|
|
1874
|
+
* Reuses forge() (bin/journey.mjs) verbatim — the same repair path already
|
|
1875
|
+
* used by `npx forgedock update` (see relinkAndHint()) — rather than
|
|
1876
|
+
* duplicating symlink/copy-mode or hook-registration logic here.
|
|
1877
|
+
*/
|
|
1878
|
+
let installRepairRan = false;
|
|
1879
|
+
async function runInstallRepairOnce() {
|
|
1880
|
+
if (installRepairRan) return;
|
|
1881
|
+
installRepairRan = true;
|
|
1882
|
+
console.log(` ${CYAN}↻${RESET} Repairing command links + SessionStart hook...`);
|
|
1883
|
+
try {
|
|
1884
|
+
const staleHook = detectSessionStartHookPath();
|
|
1885
|
+
if (staleHook.state === "dangling") {
|
|
1886
|
+
removeSessionStartHook(settingsPath);
|
|
1887
|
+
}
|
|
1888
|
+
await forge(ctx());
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
console.log(` ${RED}Repair failed:${RESET} ${err.message}`);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1252
1894
|
// ── Check 1: Command symlinks ──────────────────────────────────────────────
|
|
1253
1895
|
{
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1896
|
+
async function detectCommandFiles() {
|
|
1897
|
+
let symlinkOk = true;
|
|
1898
|
+
let checked = 0;
|
|
1899
|
+
let broken = 0;
|
|
1900
|
+
const brokenLinks = [];
|
|
1258
1901
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
broken++;
|
|
1276
|
-
brokenLinks.push(rel);
|
|
1277
|
-
symlinkOk = false;
|
|
1278
|
-
}
|
|
1279
|
-
} else {
|
|
1280
|
-
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
1281
|
-
// forge() falls back to copyFile() on EPERM/EACCES, so a regular
|
|
1282
|
-
// file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
|
|
1283
|
-
try {
|
|
1284
|
-
const srcContent = readFileSync(src, "utf-8");
|
|
1285
|
-
const tgtContent = readFileSync(tgt, "utf-8");
|
|
1286
|
-
if (srcContent !== tgtContent) {
|
|
1902
|
+
try {
|
|
1903
|
+
// Collect installable .md files from COMMANDS_DIR using the same tier filter
|
|
1904
|
+
// applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
|
|
1905
|
+
// install surface, not the raw directory walk, or it would report false failures
|
|
1906
|
+
// for 'internal' specs that were deliberately excluded from the install.
|
|
1907
|
+
const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
|
|
1908
|
+
|
|
1909
|
+
for (const src of sourceFiles) {
|
|
1910
|
+
const rel = relative(COMMANDS_DIR, src);
|
|
1911
|
+
const tgt = join(TARGET_DIR, rel);
|
|
1912
|
+
checked++;
|
|
1913
|
+
try {
|
|
1914
|
+
const lstats = await lstat(tgt);
|
|
1915
|
+
if (lstats.isSymbolicLink()) {
|
|
1916
|
+
const dest = await readlink(tgt);
|
|
1917
|
+
if (dest !== src) {
|
|
1287
1918
|
broken++;
|
|
1288
|
-
brokenLinks.push(
|
|
1919
|
+
brokenLinks.push(rel);
|
|
1920
|
+
symlinkOk = false;
|
|
1921
|
+
}
|
|
1922
|
+
} else {
|
|
1923
|
+
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
1924
|
+
// forge() falls back to copyFile() on EPERM/EACCES, so a regular
|
|
1925
|
+
// file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
|
|
1926
|
+
try {
|
|
1927
|
+
const srcContent = readFileSync(src, "utf-8");
|
|
1928
|
+
const tgtContent = readFileSync(tgt, "utf-8");
|
|
1929
|
+
if (srcContent !== tgtContent) {
|
|
1930
|
+
broken++;
|
|
1931
|
+
brokenLinks.push(`${rel} (stale copy)`);
|
|
1932
|
+
symlinkOk = false;
|
|
1933
|
+
}
|
|
1934
|
+
// else: content matches — valid copy-mode install, not broken
|
|
1935
|
+
} catch {
|
|
1936
|
+
// Could not read one of the files — treat as broken.
|
|
1937
|
+
broken++;
|
|
1938
|
+
brokenLinks.push(`${rel} (unreadable)`);
|
|
1289
1939
|
symlinkOk = false;
|
|
1290
1940
|
}
|
|
1291
|
-
// else: content matches — valid copy-mode install, not broken
|
|
1292
|
-
} catch {
|
|
1293
|
-
// Could not read one of the files — treat as broken.
|
|
1294
|
-
broken++;
|
|
1295
|
-
brokenLinks.push(`${rel} (unreadable)`);
|
|
1296
|
-
symlinkOk = false;
|
|
1297
1941
|
}
|
|
1942
|
+
} catch {
|
|
1943
|
+
// File missing
|
|
1944
|
+
broken++;
|
|
1945
|
+
brokenLinks.push(`${rel} (missing)`);
|
|
1946
|
+
symlinkOk = false;
|
|
1298
1947
|
}
|
|
1299
|
-
} catch {
|
|
1300
|
-
// File missing
|
|
1301
|
-
broken++;
|
|
1302
|
-
brokenLinks.push(`${rel} (missing)`);
|
|
1303
|
-
symlinkOk = false;
|
|
1304
1948
|
}
|
|
1949
|
+
|
|
1950
|
+
return { ok: symlinkOk, checked, broken, brokenLinks, error: null };
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
return { ok: false, checked, broken, brokenLinks, error: err.message };
|
|
1305
1953
|
}
|
|
1954
|
+
}
|
|
1306
1955
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1956
|
+
let cmdFiles = await detectCommandFiles();
|
|
1957
|
+
if (!cmdFiles.error && !cmdFiles.ok && fix) {
|
|
1958
|
+
await runInstallRepairOnce();
|
|
1959
|
+
cmdFiles = await detectCommandFiles();
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
if (cmdFiles.error) {
|
|
1963
|
+
fail("Command files", `Could not read commands directory: ${cmdFiles.error}`);
|
|
1964
|
+
} else if (cmdFiles.ok) {
|
|
1965
|
+
if (fix && installRepairRan) {
|
|
1966
|
+
fixed("Command files", `${cmdFiles.checked} files installed (repaired)`);
|
|
1309
1967
|
} else {
|
|
1310
|
-
|
|
1311
|
-
"Command files",
|
|
1312
|
-
`Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
|
|
1313
|
-
);
|
|
1968
|
+
pass("Command files", `${cmdFiles.checked} files installed`);
|
|
1314
1969
|
}
|
|
1315
|
-
}
|
|
1316
|
-
fail(
|
|
1970
|
+
} else {
|
|
1971
|
+
fail(
|
|
1972
|
+
"Command files",
|
|
1973
|
+
`Run: npx forgedock doctor --fix (or: npx forgedock install) (${cmdFiles.broken}/${cmdFiles.checked} broken: ${cmdFiles.brokenLinks.slice(0, 3).join(", ")}${cmdFiles.brokenLinks.length > 3 ? "…" : ""})`,
|
|
1974
|
+
);
|
|
1317
1975
|
}
|
|
1318
1976
|
}
|
|
1319
1977
|
|
|
@@ -1413,11 +2071,56 @@ async function doctor() {
|
|
|
1413
2071
|
|
|
1414
2072
|
if (missing.length === 0) {
|
|
1415
2073
|
pass("forge.yaml", "exists with required keys");
|
|
1416
|
-
// Extract owner and repo for the label check (simple regex, no YAML
|
|
1417
|
-
|
|
1418
|
-
|
|
2074
|
+
// Extract owner and repo for the label check (simple regex, no YAML
|
|
2075
|
+
// parser needed). Anchored on the quoted value only (not "rest of
|
|
2076
|
+
// line must be blank") so a trailing `# TODO(forgedock:...)`
|
|
2077
|
+
// comment — exactly what a placeholder/low-confidence field gets —
|
|
2078
|
+
// doesn't prevent extraction. (forge#1850: the previous `\s*$`
|
|
2079
|
+
// anchor silently failed to extract any TODO-flagged value, which
|
|
2080
|
+
// is the one case doctor most needs to see.)
|
|
2081
|
+
const ownerMatch = content.match(/^\s+owner:\s+"([^"]*)"/m);
|
|
2082
|
+
const repoMatch = content.match(/^\s+repo:\s+"([^"]*)"/m);
|
|
1419
2083
|
if (ownerMatch) forgeOwner = ownerMatch[1].trim();
|
|
1420
2084
|
if (repoMatch) forgeRepo = repoMatch[1].trim();
|
|
2085
|
+
|
|
2086
|
+
// ── Placeholder / staleness checks (forge#1850) ────────────────
|
|
2087
|
+
// A config can pass the structural check above (has all three
|
|
2088
|
+
// required top-level keys) and still be a stubbed placeholder —
|
|
2089
|
+
// catch that here so it's flagged before any pipeline command
|
|
2090
|
+
// silently consumes it (e.g. opening PRs against a wrong/guessed
|
|
2091
|
+
// branches.staging). Advisory (warn, not fail): a bare `--fast`
|
|
2092
|
+
// install with no git remote yet is a legitimate, already-tested
|
|
2093
|
+
// "get started, fill in details later" path — doctor should
|
|
2094
|
+
// surface the gap loudly without turning normal onboarding into a
|
|
2095
|
+
// broken-install exit code.
|
|
2096
|
+
if (forgeOwner === "your-github-org" || forgeRepo === "your-repo-name") {
|
|
2097
|
+
warn(
|
|
2098
|
+
"forge.yaml identity",
|
|
2099
|
+
"owner/repo are still placeholder values (your-github-org / your-repo-name). Run: npx forgedock init",
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
const todoMarkers = content.match(/# TODO\(forgedock:[a-zA-Z]+\)/g) || [];
|
|
2104
|
+
if (todoMarkers.length > 0) {
|
|
2105
|
+
const uniqueFields = [...new Set(todoMarkers)].join(", ");
|
|
2106
|
+
warn(
|
|
2107
|
+
"forge.yaml placeholders",
|
|
2108
|
+
`${todoMarkers.length} field(s) still flagged with # TODO(forgedock:...) — verify and fill in: ${uniqueFields}`,
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
|
|
2113
|
+
const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
|
|
2114
|
+
if (
|
|
2115
|
+
defaultMatch &&
|
|
2116
|
+
stagingMatch &&
|
|
2117
|
+
defaultMatch[1].trim() === stagingMatch[1].trim()
|
|
2118
|
+
) {
|
|
2119
|
+
warn(
|
|
2120
|
+
"forge.yaml branches",
|
|
2121
|
+
`branches.staging equals branches.default ('${stagingMatch[1].trim()}') — verify this is intentional (some projects have no separate staging branch) rather than an undetected 'origin/staging' fallback. See docs/CONFIG.md.`,
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
1421
2124
|
} else {
|
|
1422
2125
|
fail("forge.yaml", `Missing required keys: ${missing.join(", ")}. Edit forge.yaml or run: npx forgedock init`);
|
|
1423
2126
|
}
|
|
@@ -1427,32 +2130,47 @@ async function doctor() {
|
|
|
1427
2130
|
|
|
1428
2131
|
// ── Check 5: SessionStart hook registered ─────────────────────────────────
|
|
1429
2132
|
{
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
(
|
|
1440
|
-
h
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
2133
|
+
function detectSessionStartHookRegistered() {
|
|
2134
|
+
try {
|
|
2135
|
+
const settings = readClaudeSettings();
|
|
2136
|
+
const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
|
|
2137
|
+
? settings.hooks.SessionStart
|
|
2138
|
+
: [];
|
|
2139
|
+
const hookPresent = sessionStartEntries.some((entry) => {
|
|
2140
|
+
if (!entry || typeof entry !== "object") return false;
|
|
2141
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
2142
|
+
return hooks.some(
|
|
2143
|
+
(h) =>
|
|
2144
|
+
h &&
|
|
2145
|
+
typeof h.command === "string" &&
|
|
2146
|
+
isForgeSessionStartHook(h.command),
|
|
2147
|
+
);
|
|
2148
|
+
});
|
|
2149
|
+
return { ok: hookPresent, error: null };
|
|
2150
|
+
} catch (err) {
|
|
2151
|
+
return { ok: false, error: err.message };
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
1445
2154
|
|
|
1446
|
-
|
|
1447
|
-
|
|
2155
|
+
let hookReg = detectSessionStartHookRegistered();
|
|
2156
|
+
if (!hookReg.error && !hookReg.ok && fix) {
|
|
2157
|
+
await runInstallRepairOnce();
|
|
2158
|
+
hookReg = detectSessionStartHookRegistered();
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
if (hookReg.error) {
|
|
2162
|
+
fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
|
|
2163
|
+
} else if (hookReg.ok) {
|
|
2164
|
+
if (fix && installRepairRan) {
|
|
2165
|
+
fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
|
|
1448
2166
|
} else {
|
|
1449
|
-
|
|
1450
|
-
"SessionStart hook",
|
|
1451
|
-
"Run: npx forgedock install (writes hook entry to ~/.claude/settings.json)",
|
|
1452
|
-
);
|
|
2167
|
+
pass("SessionStart hook", "registered in ~/.claude/settings.json");
|
|
1453
2168
|
}
|
|
1454
|
-
}
|
|
1455
|
-
fail(
|
|
2169
|
+
} else {
|
|
2170
|
+
fail(
|
|
2171
|
+
"SessionStart hook",
|
|
2172
|
+
"Run: npx forgedock doctor --fix (or: npx forgedock install) (writes hook entry to ~/.claude/settings.json)",
|
|
2173
|
+
);
|
|
1456
2174
|
}
|
|
1457
2175
|
}
|
|
1458
2176
|
|
|
@@ -1491,6 +2209,124 @@ async function doctor() {
|
|
|
1491
2209
|
}
|
|
1492
2210
|
}
|
|
1493
2211
|
|
|
2212
|
+
// ── Check 5c: SessionStart hook script path integrity ─────────────────────
|
|
2213
|
+
// Check 5 only verifies a ForgeDock SessionStart hook ENTRY is registered
|
|
2214
|
+
// in settings.json — it never checks whether the script path baked into
|
|
2215
|
+
// that entry's command still exists on disk. For `npx forgedock` installs,
|
|
2216
|
+
// FORGE_HOME (and therefore the hook's script path) can resolve inside an
|
|
2217
|
+
// ephemeral npm/npx/pnpm/yarn cache directory (see isEphemeralCachePath()
|
|
2218
|
+
// in journey.mjs). If that cache is later pruned, the registered entry
|
|
2219
|
+
// still looks healthy to Check 5 while the file it points at is gone —
|
|
2220
|
+
// Claude Code sessions get no ForgeDock context injected and no error is
|
|
2221
|
+
// ever surfaced. This check catches that silent-failure case. (forge#1895)
|
|
2222
|
+
{
|
|
2223
|
+
let hookPathResult = detectSessionStartHookPath();
|
|
2224
|
+
if (hookPathResult.state === "dangling" && fix) {
|
|
2225
|
+
await runInstallRepairOnce();
|
|
2226
|
+
hookPathResult = detectSessionStartHookPath();
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (hookPathResult.state === "error") {
|
|
2230
|
+
fail("SessionStart hook script path", `Cannot verify hook script path: ${hookPathResult.error}. Run: npx forgedock install`);
|
|
2231
|
+
} else if (hookPathResult.state === "no-entry") {
|
|
2232
|
+
// No registered entry — Check 5 already reports this; nothing new here.
|
|
2233
|
+
pass("SessionStart hook script path", "skipped — no registered hook entry (see SessionStart hook check above)");
|
|
2234
|
+
} else if (hookPathResult.state === "unparseable") {
|
|
2235
|
+
warn(
|
|
2236
|
+
"SessionStart hook script path",
|
|
2237
|
+
`Could not parse a script path out of the registered hook command ("${hookPathResult.command}"). Run: npx forgedock install`,
|
|
2238
|
+
);
|
|
2239
|
+
} else if (hookPathResult.state === "ok") {
|
|
2240
|
+
if (fix && installRepairRan) {
|
|
2241
|
+
fixed("SessionStart hook script path", `refreshed to a valid path (${hookPathResult.path})`);
|
|
2242
|
+
} else {
|
|
2243
|
+
pass("SessionStart hook script path", `exists on disk (${hookPathResult.path})`);
|
|
2244
|
+
}
|
|
2245
|
+
} else {
|
|
2246
|
+
// Still dangling — either --fix wasn't passed, or the repair attempt
|
|
2247
|
+
// above failed to produce a resolvable path.
|
|
2248
|
+
fail(
|
|
2249
|
+
"SessionStart hook script path",
|
|
2250
|
+
hookPathResult.ephemeral
|
|
2251
|
+
? `${hookPathResult.path} no longer exists — it was installed from an ephemeral npm/npx/pnpm/yarn cache directory that has since been cleared. Run: npx forgedock doctor --fix (or: npm install -g forgedock then npx forgedock install)`
|
|
2252
|
+
: `${hookPathResult.path} no longer exists. Run: npx forgedock doctor --fix (or: npx forgedock install)`,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// ── Check 5d: Persisted toolset home (~/.forge) ────────────────────────────
|
|
2258
|
+
// persistHome() (bin/journey.mjs, forge#1943) copies bin/commands/scripts/
|
|
2259
|
+
// templates from wherever FORGE_HOME resolved into a stable ~/.forge/ copy
|
|
2260
|
+
// on every `npx forgedock` install/init/update run, so ~/.claude/commands
|
|
2261
|
+
// symlinks and the SessionStart hook keep working after the npm/npx cache
|
|
2262
|
+
// that originally served them is evicted. This check reports that copy's
|
|
2263
|
+
// state — it is a SIBLING of (and unrelated to) the pre-existing
|
|
2264
|
+
// ~/.forge/{runs,index} engine-data directories used by the run-issue
|
|
2265
|
+
// engine (bin/engine.mjs) and the recall knowledge index (bin/recall.mjs);
|
|
2266
|
+
// this check never reads or writes those.
|
|
2267
|
+
//
|
|
2268
|
+
// A git-clone dev install is explicitly exempt: persistHome() skips it on
|
|
2269
|
+
// purpose (a clone is already stable and user-owned — see Acceptance
|
|
2270
|
+
// Criteria #5 on #1943), so ~/.forge/{bin,commands,...} correctly does not
|
|
2271
|
+
// exist for that install mode. pass() silently rather than warn — an
|
|
2272
|
+
// absence that isn't a problem should not read as one.
|
|
2273
|
+
{
|
|
2274
|
+
try {
|
|
2275
|
+
const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
|
|
2276
|
+
if (isGitCloneInstall) {
|
|
2277
|
+
pass("Persisted toolset home (~/.forge)", "skipped — git-clone install links directly from the clone");
|
|
2278
|
+
} else {
|
|
2279
|
+
const persistedHome = join(HOME, ".forge");
|
|
2280
|
+
const payloadDirs = ["bin", "commands", "scripts", "templates"];
|
|
2281
|
+
const missingDirs = payloadDirs.filter((d) => !existsSync(join(persistedHome, d)));
|
|
2282
|
+
const versionPath = join(persistedHome, "version");
|
|
2283
|
+
|
|
2284
|
+
if (missingDirs.length === payloadDirs.length && !existsSync(versionPath)) {
|
|
2285
|
+
// Pre-migration state — persistHome() simply hasn't run yet for
|
|
2286
|
+
// this user. Not a failure: the very next `npx forgedock` run
|
|
2287
|
+
// creates it.
|
|
2288
|
+
warn(
|
|
2289
|
+
"Persisted toolset home (~/.forge)",
|
|
2290
|
+
"not yet created — run: npx forgedock install (or npx forgedock update)",
|
|
2291
|
+
);
|
|
2292
|
+
} else if (missingDirs.length > 0) {
|
|
2293
|
+
warn(
|
|
2294
|
+
"Persisted toolset home (~/.forge)",
|
|
2295
|
+
`incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
|
|
2296
|
+
);
|
|
2297
|
+
} else {
|
|
2298
|
+
// Prefer the version registry.mjs last recorded for this exact
|
|
2299
|
+
// write (getPersistedHomeState()) — the single source of truth
|
|
2300
|
+
// update()/install also write through — falling back to reading
|
|
2301
|
+
// ~/.forge/version directly on disk when the registry has no entry
|
|
2302
|
+
// (e.g. an older ForgeDock version wrote ~/.forge/ before #1943
|
|
2303
|
+
// added registry tracking). Reading both keeps doctor() honest
|
|
2304
|
+
// even if the registry ever drifts from what's actually on disk.
|
|
2305
|
+
const recorded = getPersistedHomeState();
|
|
2306
|
+
let persistedVersion = recorded?.version || "";
|
|
2307
|
+
if (!persistedVersion) {
|
|
2308
|
+
try {
|
|
2309
|
+
persistedVersion = readFileSync(versionPath, "utf-8").trim();
|
|
2310
|
+
} catch {
|
|
2311
|
+
// version file missing/unreadable — reported below as "unknown"
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
const sourceVersion = getVersion();
|
|
2315
|
+
if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
|
|
2316
|
+
warn(
|
|
2317
|
+
"Persisted toolset home (~/.forge)",
|
|
2318
|
+
`v${persistedVersion} — source is v${sourceVersion}. Run: npx forgedock update`,
|
|
2319
|
+
);
|
|
2320
|
+
} else {
|
|
2321
|
+
pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
} catch (err) {
|
|
2326
|
+
warn("Persisted toolset home (~/.forge)", `Could not verify: ${err.message}`);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
1494
2330
|
// ── Check 6: CLAUDE.md legacy managed block (cwd, informational) ──────────
|
|
1495
2331
|
// The journey-based install never injects a CLAUDE.md block — session
|
|
1496
2332
|
// context comes from the SessionStart hook (see Check 5). A block's absence
|
|
@@ -1499,32 +2335,51 @@ async function doctor() {
|
|
|
1499
2335
|
// `npx forgedock uninstall` removes it.
|
|
1500
2336
|
{
|
|
1501
2337
|
const claudeMdPath = join(process.cwd(), "CLAUDE.md");
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
"no CLAUDE.md found — session context comes from the SessionStart hook",
|
|
1506
|
-
);
|
|
1507
|
-
} else {
|
|
2338
|
+
|
|
2339
|
+
function detectClaudeMdBlock() {
|
|
2340
|
+
if (!existsSync(claudeMdPath)) return { state: "no-file", error: null };
|
|
1508
2341
|
try {
|
|
1509
2342
|
const content = readFileSync(claudeMdPath, "utf-8");
|
|
1510
|
-
if (
|
|
1511
|
-
|
|
1512
|
-
content.includes(CLAUDE_BLOCK_END)
|
|
1513
|
-
) {
|
|
1514
|
-
warn(
|
|
1515
|
-
"CLAUDE.md legacy block",
|
|
1516
|
-
`legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it`,
|
|
1517
|
-
);
|
|
1518
|
-
} else {
|
|
1519
|
-
pass(
|
|
1520
|
-
"CLAUDE.md legacy block",
|
|
1521
|
-
"no legacy ForgeDock block — session context comes from the SessionStart hook",
|
|
1522
|
-
);
|
|
2343
|
+
if (content.includes(CLAUDE_BLOCK_BEGIN) && content.includes(CLAUDE_BLOCK_END)) {
|
|
2344
|
+
return { state: "has-block", error: null };
|
|
1523
2345
|
}
|
|
2346
|
+
return { state: "no-block", error: null };
|
|
1524
2347
|
} catch (err) {
|
|
1525
|
-
|
|
2348
|
+
return { state: "error", error: err.message };
|
|
1526
2349
|
}
|
|
1527
2350
|
}
|
|
2351
|
+
|
|
2352
|
+
let claudeMdResult = detectClaudeMdBlock();
|
|
2353
|
+
let claudeMdFixed = false;
|
|
2354
|
+
if (claudeMdResult.state === "has-block" && fix) {
|
|
2355
|
+
if (removeManagedBlock(claudeMdPath) === "removed") {
|
|
2356
|
+
claudeMdFixed = true;
|
|
2357
|
+
claudeMdResult = detectClaudeMdBlock();
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
if (claudeMdResult.state === "error") {
|
|
2362
|
+
fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${claudeMdResult.error}`);
|
|
2363
|
+
} else if (claudeMdResult.state === "no-file") {
|
|
2364
|
+
pass(
|
|
2365
|
+
"CLAUDE.md legacy block",
|
|
2366
|
+
"no CLAUDE.md found — session context comes from the SessionStart hook",
|
|
2367
|
+
);
|
|
2368
|
+
} else if (claudeMdResult.state === "no-block") {
|
|
2369
|
+
if (claudeMdFixed) {
|
|
2370
|
+
fixed("CLAUDE.md legacy block", `removed legacy ForgeDock block from ${claudeMdPath}`);
|
|
2371
|
+
} else {
|
|
2372
|
+
pass(
|
|
2373
|
+
"CLAUDE.md legacy block",
|
|
2374
|
+
"no legacy ForgeDock block — session context comes from the SessionStart hook",
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
} else {
|
|
2378
|
+
warn(
|
|
2379
|
+
"CLAUDE.md legacy block",
|
|
2380
|
+
`legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it.${fix ? "" : " Run: npx forgedock doctor --fix"}`,
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
1528
2383
|
}
|
|
1529
2384
|
|
|
1530
2385
|
// ── Check 7: Required workflow labels on GitHub repo ───────────────────────
|
|
@@ -1578,18 +2433,48 @@ async function doctor() {
|
|
|
1578
2433
|
}
|
|
1579
2434
|
|
|
1580
2435
|
if (ghLabelOk) {
|
|
1581
|
-
|
|
2436
|
+
let missingLabels = expectedLabels.filter(
|
|
1582
2437
|
(l) => !existingLabels.includes(l),
|
|
1583
2438
|
);
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2439
|
+
let labelsFixed = false;
|
|
2440
|
+
|
|
2441
|
+
if (missingLabels.length > 0 && fix) {
|
|
2442
|
+
console.log(
|
|
2443
|
+
` ${CYAN}↻${RESET} Bootstrapping ${missingLabels.length} missing workflow label(s) on ${forgeOwner}/${forgeRepo}...`,
|
|
1588
2444
|
);
|
|
2445
|
+
try {
|
|
2446
|
+
await labelsSetup(`${forgeOwner}/${forgeRepo}`);
|
|
2447
|
+
labelsFixed = true;
|
|
2448
|
+
const recheckOut = execFileSync(
|
|
2449
|
+
"gh",
|
|
2450
|
+
["label", "list", "-R", `${forgeOwner}/${forgeRepo}`, "--json", "name", "--limit", "200"],
|
|
2451
|
+
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" },
|
|
2452
|
+
);
|
|
2453
|
+
existingLabels = JSON.parse(recheckOut).map((l) => l.name);
|
|
2454
|
+
missingLabels = expectedLabels.filter((l) => !existingLabels.includes(l));
|
|
2455
|
+
} catch {
|
|
2456
|
+
// labelsSetup() already reports its own per-label failures to
|
|
2457
|
+
// stdout; leave missingLabels as computed pre-fix if the
|
|
2458
|
+
// recheck itself fails (e.g. transient network error).
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
if (missingLabels.length === 0) {
|
|
2463
|
+
if (fix && labelsFixed) {
|
|
2464
|
+
fixed(
|
|
2465
|
+
"GitHub workflow labels",
|
|
2466
|
+
`all ${expectedLabels.length} workflow labels now present on ${forgeOwner}/${forgeRepo}`,
|
|
2467
|
+
);
|
|
2468
|
+
} else {
|
|
2469
|
+
pass(
|
|
2470
|
+
"GitHub workflow labels",
|
|
2471
|
+
`all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
1589
2474
|
} else {
|
|
1590
2475
|
fail(
|
|
1591
2476
|
"GitHub workflow labels",
|
|
1592
|
-
`
|
|
2477
|
+
`Run: npx forgedock doctor --fix (or: npx forgedock labels setup --repo ${forgeOwner}/${forgeRepo}) (see bin/labels.json for definitions; missing: ${missingLabels.join(", ")})`,
|
|
1593
2478
|
);
|
|
1594
2479
|
}
|
|
1595
2480
|
}
|
|
@@ -1770,8 +2655,26 @@ async function doctor() {
|
|
|
1770
2655
|
}
|
|
1771
2656
|
}
|
|
1772
2657
|
|
|
2658
|
+
// ── Check 12: GitHub App / bot token status ─────────────────────────────────
|
|
2659
|
+
// Informational only — never a hard failure. Installing the ForgeDock GitHub
|
|
2660
|
+
// App registers it against the account/org but does not, by itself, mint a
|
|
2661
|
+
// bot token: that requires the app's private key, which only the app owner
|
|
2662
|
+
// holds. Pipeline gh calls always use whatever `gh auth` context is active
|
|
2663
|
+
// locally (personal token unless the operator manually configured a bot
|
|
2664
|
+
// token) — this check exists so that state is surfaced instead of silent.
|
|
2665
|
+
// See docs/CONFIG.md "GitHub App Install" and forge#1890.
|
|
2666
|
+
{
|
|
2667
|
+
warn(
|
|
2668
|
+
"GitHub App / bot token",
|
|
2669
|
+
"pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
1773
2673
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
1774
2674
|
console.log("");
|
|
2675
|
+
if (fix && fixesApplied > 0) {
|
|
2676
|
+
console.log(`${CYAN}${BOLD}${fixesApplied} issue(s) auto-fixed.${RESET}`);
|
|
2677
|
+
}
|
|
1775
2678
|
if (failures === 0 && warnings === 0) {
|
|
1776
2679
|
console.log(`${GREEN}${BOLD}All checks passed.${RESET} ForgeDock installation is healthy.`);
|
|
1777
2680
|
} else if (failures === 0) {
|
|
@@ -1780,6 +2683,9 @@ async function doctor() {
|
|
|
1780
2683
|
console.log(
|
|
1781
2684
|
`${RED}${BOLD}${failures} check(s) failed${warnings > 0 ? `, ${warnings} warning(s)` : ""}.${RESET} See fix hints above.`,
|
|
1782
2685
|
);
|
|
2686
|
+
if (!fix) {
|
|
2687
|
+
console.log(` Some of these may be auto-fixable: run ${CYAN}npx forgedock doctor --fix${RESET}`);
|
|
2688
|
+
}
|
|
1783
2689
|
}
|
|
1784
2690
|
console.log("");
|
|
1785
2691
|
|
|
@@ -1806,6 +2712,7 @@ function help() {
|
|
|
1806
2712
|
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
1807
2713
|
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
1808
2714
|
["npx forgedock doctor", "Check installation health"],
|
|
2715
|
+
["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
|
|
1809
2716
|
["npx forgedock update", "Pull latest & reinstall"],
|
|
1810
2717
|
["npx forgedock uninstall", "Remove commands"],
|
|
1811
2718
|
["npx forgedock help", "Show this help"],
|
|
@@ -1832,12 +2739,18 @@ function help() {
|
|
|
1832
2739
|
*
|
|
1833
2740
|
* Flags:
|
|
1834
2741
|
* --dry-run Preview the assembled prompt + tool plan; no API call.
|
|
1835
|
-
* --model <id> Override the model (
|
|
2742
|
+
* --model <id> Override the model (see resolution order below).
|
|
1836
2743
|
* --max-iterations <n> Bound the tool-use loop (default: 50).
|
|
1837
2744
|
*
|
|
1838
2745
|
* The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
|
|
1839
2746
|
* dependency. The runtime itself lives in bin/runner.mjs.
|
|
1840
2747
|
*
|
|
2748
|
+
* Model resolution order (highest precedence first):
|
|
2749
|
+
* 1. --model <id> This flag.
|
|
2750
|
+
* 2. $FORGEDOCK_MODEL Env var, below.
|
|
2751
|
+
* 3. forge.yaml `agents.default_model` Read from the run's cwd, if present.
|
|
2752
|
+
* 4. Hardcoded default "claude-sonnet-5" (bin/runner.mjs DEFAULT_MODEL).
|
|
2753
|
+
*
|
|
1841
2754
|
* Env:
|
|
1842
2755
|
* FORGEDOCK_MODEL Default model id when --model is omitted.
|
|
1843
2756
|
* FORGEDOCK_SHELL Override the shell used by run_bash. Defaults to bash when
|
|
@@ -2140,7 +3053,7 @@ const KNOWN_COMMANDS = new Set([
|
|
|
2140
3053
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
2141
3054
|
"run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
|
|
2142
3055
|
]);
|
|
2143
|
-
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash();
|
|
3056
|
+
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
2144
3057
|
|
|
2145
3058
|
let exitCode = 0;
|
|
2146
3059
|
switch (command) {
|
|
@@ -2173,9 +3086,25 @@ switch (command) {
|
|
|
2173
3086
|
}
|
|
2174
3087
|
}
|
|
2175
3088
|
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
2176
|
-
statusScreen(c);
|
|
3089
|
+
await statusScreen(c);
|
|
2177
3090
|
} else {
|
|
2178
3091
|
exitCode = await runJourney(c);
|
|
3092
|
+
// Record persistHome()'s outcome (set on c.persistHomeResult inside
|
|
3093
|
+
// runJourney()) in the registry (forge#1943), mirroring update()'s npm
|
|
3094
|
+
// branch — a single, centrally-updated source of truth for doctor()
|
|
3095
|
+
// rather than each command re-deriving persisted-home state on its own
|
|
3096
|
+
// (forge#1589 split-brain precedent). Best-effort: a failed write here
|
|
3097
|
+
// must never turn a successful install into a failing one.
|
|
3098
|
+
if (c.persistHomeResult && !c.persistHomeResult.skipped) {
|
|
3099
|
+
try {
|
|
3100
|
+
await setPersistedHomeState({
|
|
3101
|
+
path: c.persistHomeResult.forgeHome,
|
|
3102
|
+
version: c.persistHomeResult.version,
|
|
3103
|
+
});
|
|
3104
|
+
} catch {
|
|
3105
|
+
// best-effort — see comment above
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
2179
3108
|
}
|
|
2180
3109
|
break;
|
|
2181
3110
|
}
|
|
@@ -2202,7 +3131,7 @@ switch (command) {
|
|
|
2202
3131
|
case "status": {
|
|
2203
3132
|
const c = ctx();
|
|
2204
3133
|
if (positional[1]) c.cwd = resolve(positional[1]);
|
|
2205
|
-
statusScreen(c);
|
|
3134
|
+
await statusScreen(c);
|
|
2206
3135
|
break;
|
|
2207
3136
|
}
|
|
2208
3137
|
case "uninstall":
|
|
@@ -2239,7 +3168,7 @@ switch (command) {
|
|
|
2239
3168
|
await demo();
|
|
2240
3169
|
break;
|
|
2241
3170
|
case "doctor":
|
|
2242
|
-
exitCode = await doctor();
|
|
3171
|
+
exitCode = await doctor(restArgs.includes("--fix"));
|
|
2243
3172
|
break;
|
|
2244
3173
|
case "report": {
|
|
2245
3174
|
const { runReport } = await import("./report.mjs");
|