forgedock 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine-cli.mjs +6 -0
- package/bin/engine.mjs +194 -59
- package/bin/forgedock.mjs +112 -5
- package/bin/hooks/pre-tool-use.mjs +29 -7
- package/bin/journey.mjs +790 -193
- package/bin/runner.mjs +159 -2
- package/bin/watch-utils.mjs +4 -1
- package/commands/cleanup.md +27 -2
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +3 -1
- package/commands/orchestrate/phase-1-resolve.md +125 -14
- package/commands/orchestrate/phase-3-dependency.md +254 -40
- package/commands/orchestrate/phase-4-execution.md +402 -39
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +97 -32
- package/commands/work-on/build/architect.md +28 -3
- package/commands/work-on/remediate.md +53 -4
- package/package.json +1 -1
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/build-knowledge-index.mjs +6 -2
- package/scripts/check-phase-registry-drift.mjs +23 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +194 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +4 -0
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/journey.mjs
CHANGED
|
@@ -747,9 +747,9 @@ export async function preflight(ctx) {
|
|
|
747
747
|
// Act II — Forging: command symlinks + SessionStart hook (Task 6)
|
|
748
748
|
// ---------------------------------------------------------------------------
|
|
749
749
|
|
|
750
|
-
import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm } from "fs/promises";
|
|
750
|
+
import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm, open } from "fs/promises";
|
|
751
751
|
import { compareVersions } from "./registry.mjs";
|
|
752
|
-
import { relative, dirname as pathDirname } from "path";
|
|
752
|
+
import { relative, dirname as pathDirname, isAbsolute } from "path";
|
|
753
753
|
import {
|
|
754
754
|
installSessionStartHook,
|
|
755
755
|
installSubagentStopHook,
|
|
@@ -855,12 +855,278 @@ async function loadCopiedManifest(manifestPath) {
|
|
|
855
855
|
/** Save the manifest atomically (mkdir recursive + .tmp+rename). */
|
|
856
856
|
async function saveCopiedManifest(manifestPath, manifest) {
|
|
857
857
|
await mkdir(pathDirname(manifestPath), { recursive: true });
|
|
858
|
-
|
|
859
|
-
|
|
858
|
+
// Suffix the tmp sibling with this process's PID so concurrent forge()
|
|
859
|
+
// invocations never share the same tmp path — mirrors the fix already
|
|
860
|
+
// applied to the copy-file branch in this same function (forge#2542).
|
|
861
|
+
const tmpPath = manifestPath + "." + process.pid + ".tmp";
|
|
862
|
+
try {
|
|
863
|
+
await writeFile(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
864
|
+
} catch (writeErr) {
|
|
865
|
+
await unlink(tmpPath).catch(() => {});
|
|
866
|
+
throw writeErr;
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
await rename(tmpPath, manifestPath);
|
|
870
|
+
} catch (renameErr) {
|
|
871
|
+
await unlink(tmpPath).catch(() => {});
|
|
872
|
+
throw renameErr;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Age (ms) below which a pid-suffixed manifest tmp sibling is assumed to be a
|
|
877
|
+
// concurrent forge()'s in-flight write and left untouched. Anything older is a
|
|
878
|
+
// crash-orphan (SIGKILL/OOM/power loss between writeFile and rename) that no
|
|
879
|
+
// future pid-matched run and no other cleanup path will ever reclaim
|
|
880
|
+
// (forge#2612). Generous relative to real write→rename latency.
|
|
881
|
+
const STALE_MANIFEST_TMP_AGE_MS = 60_000;
|
|
882
|
+
|
|
883
|
+
// Age (ms) above which a held manifest lock file is assumed to be a
|
|
884
|
+
// crash-orphan (the holder died between acquiring the lock and releasing it)
|
|
885
|
+
// rather than a live concurrent holder, and is safe to reclaim. Deliberately
|
|
886
|
+
// shorter than STALE_MANIFEST_TMP_AGE_MS (60s, forge#2612) rather than
|
|
887
|
+
// matching it: the lock's critical section (one readFile+JSON.parse, an
|
|
888
|
+
// in-memory loop, one writeFile+rename) is far lighter than the write this
|
|
889
|
+
// process guards against staleness in the tmp-sweep case, so a live holder
|
|
890
|
+
// should never legitimately hold this lock anywhere near that long. Not set
|
|
891
|
+
// as low as the original 10s either — that left too little headroom under
|
|
892
|
+
// slow I/O (AV scanning, network home dirs, loaded CI), which could plausibly
|
|
893
|
+
// exceed 10s and trigger premature reclaim of a still-live lock (review
|
|
894
|
+
// finding on PR #2654, forge#2655). 30s is a documented middle ground: 3x the
|
|
895
|
+
// original headroom, still 2x shorter than the tmp precedent.
|
|
896
|
+
const STALE_MANIFEST_LOCK_AGE_MS = 30_000;
|
|
897
|
+
|
|
898
|
+
// Bounded retry/backoff for acquireManifestLock (forge#2637). Total worst-case
|
|
899
|
+
// wait is small (a few hundred ms) — long enough to let a concurrent holder's
|
|
900
|
+
// short critical section finish, short enough that a stuck/foreign lock never
|
|
901
|
+
// meaningfully delays a forge() run before falling back to unlocked behavior.
|
|
902
|
+
const MANIFEST_LOCK_RETRY_DELAYS_MS = [10, 20, 40, 80, 150];
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Acquire an exclusive lock guarding the manifest's final read→merge→write
|
|
906
|
+
* critical section (forge#2637). Uses `fs.open(lockPath, 'wx')` — O_EXCL
|
|
907
|
+
* create — as a zero-dependency mutual-exclusion primitive: the call fails
|
|
908
|
+
* with EEXIST if another process already holds the lock, and succeeds
|
|
909
|
+
* atomically otherwise (no separate exists-check + create races).
|
|
910
|
+
*
|
|
911
|
+
* On contention, retries with the bounded backoff schedule above. Each retry
|
|
912
|
+
* also opportunistically busts a stale lock (older than
|
|
913
|
+
* STALE_MANIFEST_LOCK_AGE_MS — a crash-orphan from a holder that died before
|
|
914
|
+
* releasing) so a hard-killed process can never permanently deadlock future
|
|
915
|
+
* runs.
|
|
916
|
+
*
|
|
917
|
+
* Never throws. Returns the open file handle on success, or `null` if the
|
|
918
|
+
* lock could not be acquired after all retries — callers MUST treat `null`
|
|
919
|
+
* as "proceed without the lock" (this file's manifest-save path is
|
|
920
|
+
* best-effort/never-abort by design; the lock narrows the race window, it is
|
|
921
|
+
* not a hard correctness requirement).
|
|
922
|
+
*/
|
|
923
|
+
async function acquireManifestLock(manifestPath) {
|
|
924
|
+
const lockPath = manifestPath + ".lock";
|
|
925
|
+
for (let attempt = 0; attempt <= MANIFEST_LOCK_RETRY_DELAYS_MS.length; attempt++) {
|
|
926
|
+
try {
|
|
927
|
+
return await open(lockPath, "wx");
|
|
928
|
+
} catch (err) {
|
|
929
|
+
if (err?.code !== "EEXIST") return null; // unexpected error (e.g. EACCES) — proceed unlocked
|
|
930
|
+
// Contended — opportunistically reclaim a stale (crash-orphaned) lock.
|
|
931
|
+
let reclaimed = false;
|
|
932
|
+
try {
|
|
933
|
+
const st = await lstat(lockPath);
|
|
934
|
+
if (Date.now() - st.mtimeMs >= STALE_MANIFEST_LOCK_AGE_MS) {
|
|
935
|
+
// Re-stat immediately before deleting and only unlink if it's
|
|
936
|
+
// provably the SAME file we just judged stale (matching inode +
|
|
937
|
+
// mtime) — a plain lstat-then-unlink-by-path has a window where
|
|
938
|
+
// the original holder releases and a brand-new live holder
|
|
939
|
+
// acquires a fresh lock at this same path in between; unlinking
|
|
940
|
+
// by path alone would then delete that new holder's live lock,
|
|
941
|
+
// reproducing the exact race this lock exists to prevent, one
|
|
942
|
+
// layer down (review finding on PR #2654). If the file changed
|
|
943
|
+
// (or vanished) between the two stats, leave it alone — some
|
|
944
|
+
// other process has since claimed or released the path — and let
|
|
945
|
+
// the next retry iteration re-evaluate from scratch.
|
|
946
|
+
try {
|
|
947
|
+
const st2 = await lstat(lockPath);
|
|
948
|
+
if (st2.ino === st.ino && st2.mtimeMs === st.mtimeMs) {
|
|
949
|
+
await unlink(lockPath).catch(() => {});
|
|
950
|
+
reclaimed = true;
|
|
951
|
+
}
|
|
952
|
+
} catch {
|
|
953
|
+
// vanished between the two stats — already released, nothing to reclaim
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
// lock file vanished between the failed open and this stat (the
|
|
958
|
+
// holder released it) — fall through to the next attempt/retry.
|
|
959
|
+
}
|
|
960
|
+
// A successful reclaim just proved the path is free — retry open()
|
|
961
|
+
// immediately instead of waiting out the full backoff delay for this
|
|
962
|
+
// iteration (review finding on PR #2654, forge#2656). Still counts as
|
|
963
|
+
// a used attempt (loop counter still advances), it just skips the
|
|
964
|
+
// sleep.
|
|
965
|
+
if (reclaimed) continue;
|
|
966
|
+
if (attempt === MANIFEST_LOCK_RETRY_DELAYS_MS.length) return null; // retries exhausted
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, MANIFEST_LOCK_RETRY_DELAYS_MS[attempt]));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Release a lock acquired by acquireManifestLock() (forge#2637). Closes the
|
|
975
|
+
* handle and removes the lock file. Never throws — same best-effort contract
|
|
976
|
+
* as the rest of the manifest-save path; a failure to release cleanly is
|
|
977
|
+
* recovered by the next contender's stale-lock reclaim in
|
|
978
|
+
* acquireManifestLock() rather than by this function succeeding.
|
|
979
|
+
*/
|
|
980
|
+
async function releaseManifestLock(manifestPath, handle) {
|
|
981
|
+
if (!handle) return;
|
|
982
|
+
await handle.close().catch(() => {});
|
|
983
|
+
await unlink(manifestPath + ".lock").catch(() => {});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Best-effort sweep of crash-orphaned pid-suffixed manifest tmp siblings
|
|
988
|
+
* (forge#2612). saveCopiedManifest() writes to `manifestPath.<pid>.tmp` then
|
|
989
|
+
* renames it into place; if the process is hard-killed in that window the
|
|
990
|
+
* pid-suffixed tmp is orphaned permanently — no future run reuses that exact
|
|
991
|
+
* pid (unlike the old shared literal `.tmp` name any run would overwrite) and
|
|
992
|
+
* no other cleanup path scans this directory. Removes siblings matching
|
|
993
|
+
* `<manifest-basename>.<digits>.tmp` that are older than
|
|
994
|
+
* STALE_MANIFEST_TMP_AGE_MS (so a concurrent forge()'s in-flight tmp is never
|
|
995
|
+
* deleted) and are not this process's own live tmp. Never throws —
|
|
996
|
+
* housekeeping only, same never-abort contract as the manifest-save guard.
|
|
997
|
+
*/
|
|
998
|
+
async function sweepStaleManifestTmps(manifestPath) {
|
|
999
|
+
const dir = pathDirname(manifestPath);
|
|
1000
|
+
const base = basename(manifestPath);
|
|
1001
|
+
const liveTmp = base + "." + process.pid + ".tmp";
|
|
1002
|
+
// Match only `<base>.<digits>.tmp` — the exact pid-suffixed shape. The legacy
|
|
1003
|
+
// plain `<base>.tmp` (no digit segment) is intentionally excluded so a
|
|
1004
|
+
// foreign/legacy sibling is never reclaimed here.
|
|
1005
|
+
const stalePattern = new RegExp(
|
|
1006
|
+
"^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\.\\d+\\.tmp$",
|
|
1007
|
+
);
|
|
1008
|
+
let entries;
|
|
1009
|
+
try {
|
|
1010
|
+
entries = await readdir(dir);
|
|
1011
|
+
} catch {
|
|
1012
|
+
return; // dir missing/unreadable — nothing to reclaim
|
|
1013
|
+
}
|
|
1014
|
+
const now = Date.now();
|
|
1015
|
+
await Promise.all(
|
|
1016
|
+
entries.map(async (name) => {
|
|
1017
|
+
if (name === liveTmp || !stalePattern.test(name)) return;
|
|
1018
|
+
const full = join(dir, name);
|
|
1019
|
+
try {
|
|
1020
|
+
const st = await lstat(full);
|
|
1021
|
+
if (now - st.mtimeMs < STALE_MANIFEST_TMP_AGE_MS) return; // maybe in-flight
|
|
1022
|
+
await unlink(full);
|
|
1023
|
+
} catch {
|
|
1024
|
+
// best-effort — ignore races / permission errors
|
|
1025
|
+
}
|
|
1026
|
+
}),
|
|
1027
|
+
);
|
|
860
1028
|
}
|
|
861
1029
|
|
|
862
1030
|
const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
|
|
863
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Verify a freshly created symlink is actually readable by *this* process
|
|
1034
|
+
* before trusting it. `fs.symlink()` can return successfully (no EPERM/
|
|
1035
|
+
* EACCES — so `isLinkPermissionError()` never fires) while still producing a
|
|
1036
|
+
* link the OS refuses to resolve for this security/runtime context. The
|
|
1037
|
+
* concrete case this guards against (forge#2620): installing under Git Bash
|
|
1038
|
+
* (MSYS2) on Windows creates an MSYS-style symlink; the reparse point itself
|
|
1039
|
+
* is written successfully, but a native Windows process (the Node binary
|
|
1040
|
+
* behind Claude Code) later hits `"The path cannot be traversed because it
|
|
1041
|
+
* contains an untrusted mount point"` when it tries to read through it. That
|
|
1042
|
+
* failure surfaces at *read* time, not at *creation* time, and with an error
|
|
1043
|
+
* code (`UNKNOWN`/`EIO`, not `EPERM`/`EACCES`) that the existing permission
|
|
1044
|
+
* gate doesn't recognize — so the copy-fallback path never engaged and a
|
|
1045
|
+
* dead, unreadable link was left in place.
|
|
1046
|
+
*
|
|
1047
|
+
* Reading the link back immediately after creating it catches this (and any
|
|
1048
|
+
* other silently-broken-symlink case) regardless of the specific error code
|
|
1049
|
+
* involved, without needing to enumerate every possible OS/runtime error.
|
|
1050
|
+
*
|
|
1051
|
+
* @param {string} target - Path to the symlink just created.
|
|
1052
|
+
* @returns {Promise<boolean>} true if the symlink resolves and is readable.
|
|
1053
|
+
*/
|
|
1054
|
+
export async function isSymlinkTraversable(target) {
|
|
1055
|
+
try {
|
|
1056
|
+
await readFile(target);
|
|
1057
|
+
return true;
|
|
1058
|
+
} catch {
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Atomically install `target` as a symlink to `file`, verifying the result is
|
|
1065
|
+
* readable by *this* process. Single source of truth for the correctness
|
|
1066
|
+
* invariants that previously lived as near-verbatim copies at every symlink
|
|
1067
|
+
* call site (forge#2667):
|
|
1068
|
+
*
|
|
1069
|
+
* - The symlink is first created at a tmp sibling suffixed with this
|
|
1070
|
+
* process's PID, then rename()d onto target. symlink() is exclusive-create,
|
|
1071
|
+
* so two concurrent forge() invocations installing the same path would
|
|
1072
|
+
* collide with an uncaught EEXIST on a shared literal path — the PID
|
|
1073
|
+
* suffix guarantees they never share one (forge#2542, forge#2599,
|
|
1074
|
+
* forge#2600, forge#2631). rename() atomically replaces any existing
|
|
1075
|
+
* target, including on Windows.
|
|
1076
|
+
* - The tmp sibling is unlinked on every failure path so a rename failure
|
|
1077
|
+
* (or any rethrow) never orphans it (forge#2612).
|
|
1078
|
+
* - The link is read back via isSymlinkTraversable() on the PID-scoped tmp
|
|
1079
|
+
* path *before* the rename: fs.symlink() can succeed while producing a link
|
|
1080
|
+
* this runtime cannot resolve (MSYS symlink + native Windows "untrusted
|
|
1081
|
+
* mount point" — forge#2620). Verifying the tmp sibling — which no other
|
|
1082
|
+
* process ever touches — instead of the shared target *after* the rename
|
|
1083
|
+
* closes a TOCTOU window: a post-rename readback could describe (and the
|
|
1084
|
+
* old cleanup unlink could remove) a concurrent installer's freshly-renamed
|
|
1085
|
+
* link, not ours (forge#2679). An unreadable link is discarded as tmp and
|
|
1086
|
+
* never renamed onto target, so target is left untouched.
|
|
1087
|
+
*
|
|
1088
|
+
* Callers keep their own `wantSymlink` guard and site-specific bookkeeping
|
|
1089
|
+
* (counters, manifest mutation, copy fall-through) around the result.
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} file - Symlink target (the source file to link to).
|
|
1092
|
+
* @param {string} target - Path where the symlink is installed.
|
|
1093
|
+
* @returns {Promise<"linked"|"unreadable"|"denied">}
|
|
1094
|
+
* "linked" — symlink atomically installed at target and traversable.
|
|
1095
|
+
* "unreadable" — symlink was created but not readable back (forge#2620);
|
|
1096
|
+
* the dead link was discarded as tmp and the rename never
|
|
1097
|
+
* happened, so any existing target is left intact (forge#2679).
|
|
1098
|
+
* Caller should fall back to a copy.
|
|
1099
|
+
* "denied" — EPERM/EACCES from symlink/rename (no symlink installed;
|
|
1100
|
+
* e.g. Windows without Developer Mode). Caller should fall
|
|
1101
|
+
* back to a copy.
|
|
1102
|
+
* Any other error (EEXIST on the tmp path, ENOSPC, rename failures, …) is
|
|
1103
|
+
* rethrown after tmp cleanup.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function atomicSymlinkInstall(file, target) {
|
|
1106
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1107
|
+
try {
|
|
1108
|
+
await symlink(file, tmpTarget);
|
|
1109
|
+
// Read back the PID-scoped tmp sibling *before* renaming onto target. No
|
|
1110
|
+
// other process touches tmpTarget, so the verdict cannot describe a
|
|
1111
|
+
// concurrent installer's link, and an unreadable link is discarded here
|
|
1112
|
+
// without ever unlinking target (forge#2679, forge#2620).
|
|
1113
|
+
if (!(await isSymlinkTraversable(tmpTarget))) {
|
|
1114
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1115
|
+
return "unreadable";
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
await rename(tmpTarget, target);
|
|
1119
|
+
} catch (renameErr) {
|
|
1120
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1121
|
+
throw renameErr;
|
|
1122
|
+
}
|
|
1123
|
+
return "linked";
|
|
1124
|
+
} catch (linkErr) {
|
|
1125
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1126
|
+
return "denied";
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
864
1130
|
/**
|
|
865
1131
|
* Allowlist of universal pipeline-agent scripts installed to
|
|
866
1132
|
* ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
|
|
@@ -941,6 +1207,108 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
|
|
|
941
1207
|
return pruned;
|
|
942
1208
|
}
|
|
943
1209
|
|
|
1210
|
+
/**
|
|
1211
|
+
* Remove leftover extensionless entries at the top level of targetDir left
|
|
1212
|
+
* behind by prior `npx forgedock <command>` runs (forge#2620). Those older
|
|
1213
|
+
* ephemeral invocations created extensionless symlinks/directories (e.g.
|
|
1214
|
+
* `orchestrate`, `pipeline-health`) pointing into the npx download cache
|
|
1215
|
+
* (`%LocalAppData%\npm-cache\_npx\...` / `~/.npm/_npx/...`). Those cache
|
|
1216
|
+
* targets are routinely evicted by npx/npm cache cleanup or OS temp
|
|
1217
|
+
* cleanup, leaving dead entries that collide on the base command name with
|
|
1218
|
+
* the real `{name}.md` file this installer is about to create —
|
|
1219
|
+
* `mkdir(..., {recursive:true})` throws when a same-named non-directory
|
|
1220
|
+
* file already occupies that path segment.
|
|
1221
|
+
*
|
|
1222
|
+
* Conservative by design — must run BEFORE the main install loop so stale
|
|
1223
|
+
* entries are cleared before any mkdir/symlink attempt can collide with
|
|
1224
|
+
* them this run (pruneOrphanedSymlinks above only catches entries that
|
|
1225
|
+
* point *into commandsDir*, and runs after the loop; this catches the
|
|
1226
|
+
* separate npx-cache-origin case pre-emptively). Only removes a top-level
|
|
1227
|
+
* entry when its basename has no file extension (`.md` files and dotfiles
|
|
1228
|
+
* are never touched) AND it is a symlink whose target either no longer
|
|
1229
|
+
* exists or resolves into a known ephemeral cache path
|
|
1230
|
+
* (`isEphemeralCachePath`). Real command directories (e.g. `work-on/`)
|
|
1231
|
+
* created by this installer are plain directories containing `.md` files,
|
|
1232
|
+
* never symlinks — untouched by either condition.
|
|
1233
|
+
*
|
|
1234
|
+
* @param {string} targetDir - ~/.claude/commands
|
|
1235
|
+
* @returns {Promise<number>} Number of stale entries removed.
|
|
1236
|
+
*/
|
|
1237
|
+
// Matches a Windows drive-relative path like "C:foo" (drive letter + colon,
|
|
1238
|
+
// NOT followed by a path separator) — as opposed to a drive-absolute path
|
|
1239
|
+
// like "C:\foo" or "C:/foo", which path.isAbsolute() already recognizes
|
|
1240
|
+
// correctly. A drive-relative target resolves relative to that drive's own
|
|
1241
|
+
// current working directory, which Node has no API to query — there is no
|
|
1242
|
+
// way to compute the correct absolute path here (review finding on PR #2658,
|
|
1243
|
+
// forge#2659). Deliberately narrow: only fires when isAbsolute() has already
|
|
1244
|
+
// said "no" and the string still starts with `<letter>:` immediately
|
|
1245
|
+
// followed by a non-separator character. Platform-gated (review finding
|
|
1246
|
+
// forge#2663): on POSIX, `:` has no special meaning in filenames, so a
|
|
1247
|
+
// literal relative target like `C:foo` is a perfectly valid path component
|
|
1248
|
+
// and must NOT be misclassified as an unresolvable Windows drive-relative
|
|
1249
|
+
// path — only apply this heuristic on win32.
|
|
1250
|
+
const WINDOWS_DRIVE_RELATIVE_RE = /^[A-Za-z]:(?![\\/])/;
|
|
1251
|
+
|
|
1252
|
+
export async function pruneStaleExtensionlessEntries(targetDir) {
|
|
1253
|
+
let pruned = 0;
|
|
1254
|
+
let entries;
|
|
1255
|
+
try {
|
|
1256
|
+
entries = await readdir(targetDir, { withFileTypes: true });
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
if (err.code === "ENOENT") return pruned;
|
|
1259
|
+
throw err;
|
|
1260
|
+
}
|
|
1261
|
+
for (const entry of entries) {
|
|
1262
|
+
const name = entry.name;
|
|
1263
|
+
if (name.includes(".")) continue; // has an extension (or is a dotfile) — never touch
|
|
1264
|
+
if (!entry.isSymbolicLink()) continue; // only manage symlinks here — real dirs/files are left alone
|
|
1265
|
+
const full = join(targetDir, name);
|
|
1266
|
+
let linkTarget;
|
|
1267
|
+
try {
|
|
1268
|
+
linkTarget = await readlink(full);
|
|
1269
|
+
} catch {
|
|
1270
|
+
continue; // unreadable link metadata — skip rather than guess
|
|
1271
|
+
}
|
|
1272
|
+
// readlink() returns whatever string was stored at symlink-creation time.
|
|
1273
|
+
// A relative target is defined (POSIX) relative to the symlink's own
|
|
1274
|
+
// containing directory (targetDir, since full = join(targetDir, name)) —
|
|
1275
|
+
// not the process cwd, which is what a raw lstat(linkTarget) would use.
|
|
1276
|
+
//
|
|
1277
|
+
// Two shapes isAbsolute() alone misclassifies as "relative" and would
|
|
1278
|
+
// otherwise be wrongly joined onto targetDir:
|
|
1279
|
+
// - Windows drive-relative ("C:foo") — see WINDOWS_DRIVE_RELATIVE_RE
|
|
1280
|
+
// above; unresolvable here, so the entry is left untouched. Only
|
|
1281
|
+
// meaningful on win32 (review finding forge#2663) — on POSIX, `:` is
|
|
1282
|
+
// just an ordinary filename character, so this check is skipped
|
|
1283
|
+
// there and the target falls through to normal relative resolution.
|
|
1284
|
+
// - Shell-style tilde ("~/foo", "~\foo", or bare "~") — expand against
|
|
1285
|
+
// os.homedir() first, matching shell semantics (review finding on
|
|
1286
|
+
// PR #2658, forge#2660).
|
|
1287
|
+
if (process.platform === "win32" && WINDOWS_DRIVE_RELATIVE_RE.test(linkTarget)) continue; // can't resolve without the drive's own cwd — leave alone
|
|
1288
|
+
let effectiveTarget = linkTarget;
|
|
1289
|
+
if (effectiveTarget === "~" || effectiveTarget.startsWith("~/") || effectiveTarget.startsWith("~\\")) {
|
|
1290
|
+
effectiveTarget = join(os.homedir(), effectiveTarget.slice(1));
|
|
1291
|
+
}
|
|
1292
|
+
const resolvedTarget = isAbsolute(effectiveTarget) ? effectiveTarget : join(targetDir, effectiveTarget);
|
|
1293
|
+
let targetMissing = false;
|
|
1294
|
+
try {
|
|
1295
|
+
await lstat(resolvedTarget);
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
|
|
1298
|
+
targetMissing = true;
|
|
1299
|
+
}
|
|
1300
|
+
if (targetMissing || isEphemeralCachePath(linkTarget)) {
|
|
1301
|
+
try {
|
|
1302
|
+
await unlink(full);
|
|
1303
|
+
pruned++;
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
if (err.code !== "ENOENT") throw err;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return pruned;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
944
1312
|
/**
|
|
945
1313
|
* Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
|
|
946
1314
|
* directory rather than a durable install location (global npm install,
|
|
@@ -1378,12 +1746,7 @@ async function linkPipelineScripts(ctx) {
|
|
|
1378
1746
|
|
|
1379
1747
|
let linked = false;
|
|
1380
1748
|
if (wantSymlink) {
|
|
1381
|
-
|
|
1382
|
-
await symlink(file, target);
|
|
1383
|
-
linked = true;
|
|
1384
|
-
} catch (linkErr) {
|
|
1385
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1386
|
-
}
|
|
1749
|
+
linked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
1387
1750
|
}
|
|
1388
1751
|
if (!linked) {
|
|
1389
1752
|
await copyFile(file, target);
|
|
@@ -1520,208 +1883,272 @@ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
|
|
|
1520
1883
|
}
|
|
1521
1884
|
|
|
1522
1885
|
/**
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
* On Windows without Developer Mode (symlink → EPERM/EACCES) each command is
|
|
1528
|
-
* copied instead, and recorded in a manifest so re-runs can tell ForgeDock's
|
|
1529
|
-
* own copies apart from user-owned files. ctx.linkStrategy ("symlink" default)
|
|
1530
|
-
* can be set to "copy" to skip all symlink attempts (deterministic tests).
|
|
1886
|
+
* Final manifest save for forge(): locked re-read → replay this run's
|
|
1887
|
+
* `manifestOps` → write. Returns true on success, false on any failure —
|
|
1888
|
+
* never throws, because manifest housekeeping must never abort the caller's
|
|
1889
|
+
* receipt or the hook installation.
|
|
1531
1890
|
*/
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1555
|
-
};
|
|
1556
|
-
|
|
1557
|
-
for (let i = 0; i < files.length; i++) {
|
|
1558
|
-
const file = files[i];
|
|
1559
|
-
const rel = relative(commandsDir, file);
|
|
1560
|
-
const target = join(targetDir, rel);
|
|
1561
|
-
await mkdir(pathDirname(target), { recursive: true });
|
|
1562
|
-
|
|
1891
|
+
async function saveManifestWithMerge(manifestPath, manifestOps) {
|
|
1892
|
+
try {
|
|
1893
|
+
// Re-read-and-merge (forge#2614): another concurrent forge() invocation
|
|
1894
|
+
// may have saved its own manifest.files adds/deletes since this run's
|
|
1895
|
+
// initial load above. Re-reading the on-disk manifest right before this
|
|
1896
|
+
// run's own save and replaying only this run's own ops (manifestOps)
|
|
1897
|
+
// onto that fresh copy — rather than blindly overwriting with the
|
|
1898
|
+
// run-start in-memory snapshot — means this run's save can no longer
|
|
1899
|
+
// silently drop the other run's changes. loadCopiedManifest() never
|
|
1900
|
+
// throws (missing/corrupt on-disk manifest resolves to an empty one),
|
|
1901
|
+
// so this stays within the surrounding never-abort contract.
|
|
1902
|
+
//
|
|
1903
|
+
// Lock the re-read→merge→write sequence itself (forge#2637): #2614
|
|
1904
|
+
// narrowed the race from "whole run duration" to this critical section,
|
|
1905
|
+
// but left it unprotected — a third concurrent forge() could still land
|
|
1906
|
+
// its own save inside the gap between this run's re-read and its own
|
|
1907
|
+
// write-rename completing. acquireManifestLock() is best-effort: on
|
|
1908
|
+
// failure to acquire (contention exhausted retries, or an unexpected
|
|
1909
|
+
// error) it returns null and this run proceeds unlocked, same as
|
|
1910
|
+
// before — the lock narrows the window further, it does not change the
|
|
1911
|
+
// never-abort contract.
|
|
1912
|
+
const lockHandle = await acquireManifestLock(manifestPath);
|
|
1563
1913
|
try {
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
skipped++;
|
|
1914
|
+
const mergedManifest = await loadCopiedManifest(manifestPath);
|
|
1915
|
+
for (const { rel, op } of manifestOps) {
|
|
1916
|
+
if (op === "add") {
|
|
1917
|
+
mergedManifest.files[rel] = true;
|
|
1569
1918
|
} else {
|
|
1570
|
-
|
|
1571
|
-
if (wantSymlink) {
|
|
1572
|
-
try {
|
|
1573
|
-
await symlink(file, target + ".tmp");
|
|
1574
|
-
try {
|
|
1575
|
-
await rename(target + ".tmp", target);
|
|
1576
|
-
} catch (renameErr) {
|
|
1577
|
-
await unlink(target + ".tmp").catch(() => {});
|
|
1578
|
-
throw renameErr;
|
|
1579
|
-
}
|
|
1580
|
-
updated++;
|
|
1581
|
-
relinked = true;
|
|
1582
|
-
} catch (linkErr) {
|
|
1583
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
if (!relinked) {
|
|
1587
|
-
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
1588
|
-
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
1589
|
-
await unlink(target);
|
|
1590
|
-
await copyFile(file, target);
|
|
1591
|
-
updated++; // replaces an existing managed entry
|
|
1592
|
-
recordCopy(rel);
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
} else if (manifest.files[rel]) {
|
|
1596
|
-
// A regular file we copied on a previous run — ours to manage.
|
|
1597
|
-
// First try upgrading it to a symlink (Developer Mode enabled since).
|
|
1598
|
-
let upgraded = false;
|
|
1599
|
-
if (wantSymlink) {
|
|
1600
|
-
try {
|
|
1601
|
-
await symlink(file, target + ".tmp");
|
|
1602
|
-
try {
|
|
1603
|
-
await rename(target + ".tmp", target); // rename replaces existing files on Windows
|
|
1604
|
-
} catch (renameErr) {
|
|
1605
|
-
await unlink(target + ".tmp").catch(() => {});
|
|
1606
|
-
throw renameErr;
|
|
1607
|
-
}
|
|
1608
|
-
updated++;
|
|
1609
|
-
delete manifest.files[rel];
|
|
1610
|
-
manifestChanged = true;
|
|
1611
|
-
upgraded = true;
|
|
1612
|
-
} catch (linkErr) {
|
|
1613
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
if (!upgraded) {
|
|
1617
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1618
|
-
if (src.equals(dst)) {
|
|
1619
|
-
skipped++;
|
|
1620
|
-
} else {
|
|
1621
|
-
await copyFile(file, target);
|
|
1622
|
-
updated++;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
} else {
|
|
1626
|
-
// Regular file not in manifest — could be a copy from a pre-manifest
|
|
1627
|
-
// ForgeDock version, or a user-owned file. Content-compare: if it
|
|
1628
|
-
// matches source exactly, adopt it into the manifest silently (it was
|
|
1629
|
-
// ours). If it differs, leave it untouched — it may be user-customized.
|
|
1630
|
-
let adopted = false;
|
|
1631
|
-
try {
|
|
1632
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1633
|
-
if (src.equals(dst)) {
|
|
1634
|
-
recordCopy(rel);
|
|
1635
|
-
skipped++;
|
|
1636
|
-
adopted = true;
|
|
1637
|
-
}
|
|
1638
|
-
} catch { /* readFile failure — fall through to warning */ }
|
|
1639
|
-
if (!adopted) {
|
|
1640
|
-
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
1641
|
-
w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
|
|
1642
|
-
skipped++;
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
} catch (err) {
|
|
1646
|
-
if (err.code !== "ENOENT") throw err;
|
|
1647
|
-
let linked = false;
|
|
1648
|
-
if (wantSymlink) {
|
|
1649
|
-
try {
|
|
1650
|
-
await symlink(file, target);
|
|
1651
|
-
installed++;
|
|
1652
|
-
linked = true;
|
|
1653
|
-
if (manifest.files[rel]) {
|
|
1654
|
-
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
1655
|
-
delete manifest.files[rel];
|
|
1656
|
-
manifestChanged = true;
|
|
1657
|
-
}
|
|
1658
|
-
} catch (linkErr) {
|
|
1659
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1919
|
+
delete mergedManifest.files[rel];
|
|
1660
1920
|
}
|
|
1661
1921
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
copied++;
|
|
1666
|
-
recordCopy(rel);
|
|
1667
|
-
}
|
|
1922
|
+
await saveCopiedManifest(manifestPath, mergedManifest);
|
|
1923
|
+
} finally {
|
|
1924
|
+
await releaseManifestLock(manifestPath, lockHandle);
|
|
1668
1925
|
}
|
|
1926
|
+
return true;
|
|
1927
|
+
} catch {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1669
1931
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1932
|
+
/**
|
|
1933
|
+
* Per-file branch for forge(): a manifest-tracked regular file — a copy we
|
|
1934
|
+
* made on a previous run, ours to manage. First try upgrading it to a
|
|
1935
|
+
* symlink (Developer Mode enabled since), preserving atomicSymlinkInstall()'s
|
|
1936
|
+
* tri-state "linked"/"unreadable"/"denied" contract (forge#2620 / ADR #2667)
|
|
1937
|
+
* exactly; on "denied", fall through to content-compare + copy.
|
|
1938
|
+
* Returns counter deltas for forge() to fold in.
|
|
1939
|
+
*/
|
|
1940
|
+
async function upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete }) {
|
|
1941
|
+
let upgraded = false;
|
|
1942
|
+
let restoredAsCopy = false;
|
|
1943
|
+
if (wantSymlink) {
|
|
1944
|
+
const result = await atomicSymlinkInstall(file, target);
|
|
1945
|
+
if (result === "linked") {
|
|
1946
|
+
upgraded = true;
|
|
1947
|
+
recordDelete(rel);
|
|
1948
|
+
} else if (result === "unreadable") {
|
|
1949
|
+
// Symlink was created but is not readable back (forge#2620) — the helper
|
|
1950
|
+
// discarded it as tmp and left the existing copy at target untouched
|
|
1951
|
+
// (forge#2679). Overwrite it with a fresh copy so the command stays
|
|
1952
|
+
// readable. Keep the manifest entry (still copy-managed, not
|
|
1953
|
+
// symlink-managed) and do NOT fall through to the generic
|
|
1954
|
+
// copy-comparison below — the copy is already done.
|
|
1955
|
+
await copyFile(file, target);
|
|
1956
|
+
restoredAsCopy = true;
|
|
1674
1957
|
}
|
|
1958
|
+
// "denied" — no symlink installed; fall through to content-compare.
|
|
1675
1959
|
}
|
|
1676
|
-
if (
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
1687
|
-
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
1688
|
-
|
|
1689
|
-
// Install enforcement hooks (#1250): PreToolUse (branch/label validation)
|
|
1690
|
-
// and SubagentStop interactive engine adapter. Both are idempotent and
|
|
1691
|
-
// always installed (fail-open if settings.json is malformed, same
|
|
1692
|
-
// contract as SessionStart hook).
|
|
1693
|
-
const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
|
|
1694
|
-
const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
|
|
1695
|
-
const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
|
|
1696
|
-
installSubagentStopHook(settingsPath, subagentStopScript);
|
|
1960
|
+
if (upgraded || restoredAsCopy) {
|
|
1961
|
+
return { updatedDelta: 1, skippedDelta: 0 };
|
|
1962
|
+
}
|
|
1963
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1964
|
+
if (src.equals(dst)) {
|
|
1965
|
+
return { updatedDelta: 0, skippedDelta: 1 };
|
|
1966
|
+
}
|
|
1967
|
+
await copyFile(file, target);
|
|
1968
|
+
return { updatedDelta: 1, skippedDelta: 0 };
|
|
1969
|
+
}
|
|
1697
1970
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1971
|
+
/**
|
|
1972
|
+
* Per-file branch for forge(): a regular file not in the manifest — a copy
|
|
1973
|
+
* from a pre-manifest ForgeDock version, or a stale copy that was never
|
|
1974
|
+
* adopted. `rel` here is always enumerated from `files` (ForgeDock's own
|
|
1975
|
+
* commandsDir listing) — every path this branch touches is a
|
|
1976
|
+
* ForgeDock-managed command file, never an arbitrary user file, so there is
|
|
1977
|
+
* no "user-customized" case to protect. Content-compare: if it matches
|
|
1978
|
+
* source, adopt it into the manifest silently (it was ours all along). If it
|
|
1979
|
+
* differs, it's stale — overwrite it and adopt it into the manifest too,
|
|
1980
|
+
* mirroring the manifest-tracked branch, so future runs take the
|
|
1981
|
+
* manifest-aware fast path.
|
|
1982
|
+
*
|
|
1983
|
+
* Never throws. Returns an effects object forge() folds into its counters:
|
|
1984
|
+
* `handled: false` means every repair attempt failed and forge() should
|
|
1985
|
+
* print the per-file WARNING (terminal/progress-bar state stays in forge()).
|
|
1986
|
+
* `backedUpDelta` is mutated in place as backup/rollback progresses so a
|
|
1987
|
+
* mid-sequence failure still reports any backup that remains on disk
|
|
1988
|
+
* (forge#2559).
|
|
1989
|
+
*/
|
|
1990
|
+
async function adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy }) {
|
|
1991
|
+
const effects = { handled: false, updatedDelta: 0, skippedDelta: 0, backedUpDelta: 0 };
|
|
1992
|
+
try {
|
|
1993
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1994
|
+
if (src.equals(dst)) {
|
|
1995
|
+
recordCopy(rel);
|
|
1996
|
+
effects.skippedDelta++;
|
|
1997
|
+
effects.handled = true;
|
|
1998
|
+
} else {
|
|
1999
|
+
// Write to a .tmp sibling and atomically rename onto target — never
|
|
2000
|
+
// open target itself for a direct overwrite. copyFile() is not
|
|
2001
|
+
// atomic: a mid-write failure (AV lock / ENOSPC / process kill)
|
|
2002
|
+
// would otherwise leave target truncated/corrupted rather than
|
|
2003
|
+
// simply stale, which the WARNING in forge() cannot distinguish. This
|
|
2004
|
+
// mirrors the .tmp+rename swap already used for symlink installs
|
|
2005
|
+
// (lines ~1573-1579, ~1601-1607).
|
|
2006
|
+
//
|
|
2007
|
+
// Unlike those symlink-based branches — where symlink() is itself
|
|
2008
|
+
// exclusive-create and therefore naturally races out a concurrent
|
|
2009
|
+
// writer — copyFile() has no such semantics: two concurrent
|
|
2010
|
+
// forge() invocations (e.g. an overlapping `doctor --fix` run, or
|
|
2011
|
+
// a file watcher) targeting the same file would both write
|
|
2012
|
+
// through the same shared `target + ".tmp"` path and could
|
|
2013
|
+
// interleave or clobber each other before either rename() runs.
|
|
2014
|
+
// Suffix the tmp sibling with this process's PID so concurrent
|
|
2015
|
+
// invocations never share a path (forge#2542).
|
|
2016
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
2017
|
+
try {
|
|
2018
|
+
await copyFile(file, tmpTarget);
|
|
2019
|
+
} catch (copyErr) {
|
|
2020
|
+
// A copyFile failure (disk full mid-copy, AV lock, permission
|
|
2021
|
+
// error) can still leave a partially-written .tmp sibling on
|
|
2022
|
+
// disk even though the write itself threw. The sibling
|
|
2023
|
+
// rename() failure just below already cleans up tmpTarget
|
|
2024
|
+
// on its own failure path (renameErr) — mirror that here so a
|
|
2025
|
+
// copyFile failure doesn't orphan the .tmp file instead
|
|
2026
|
+
// (forge#2540). Best-effort: never let the cleanup itself mask
|
|
2027
|
+
// or replace the original copyErr being rethrown.
|
|
2028
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2029
|
+
throw copyErr;
|
|
2030
|
+
}
|
|
2031
|
+
// Preserve the pre-existing content before it's replaced, mirroring
|
|
2032
|
+
// half of the confirm+backup convention used by the forge.yaml
|
|
2033
|
+
// overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
|
|
2034
|
+
// review(), both via backupExisting()). A confirmation prompt is
|
|
2035
|
+
// deliberately NOT added: this branch also runs non-interactively
|
|
2036
|
+
// inside `doctor --fix`'s automated repair (bin/forgedock.mjs
|
|
2037
|
+
// runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
|
|
2038
|
+
// input would break that flow. Placed here — after the .tmp write
|
|
2039
|
+
// has already succeeded, before the final rename — rather than
|
|
2040
|
+
// before copyFile (as the forge.yaml path does), so a copyFile
|
|
2041
|
+
// failure never backs up (and thereby displaces) a target that's
|
|
2042
|
+
// about to remain untouched (see the .tmp-write-failure test,
|
|
2043
|
+
// forge#2498). backupExisting()'s rename is itself atomic, so this
|
|
2044
|
+
// ordering carries none of the non-atomic-write risk that forge#1396
|
|
2045
|
+
// flagged for the old backup-then-writeFileSync forge.yaml path.
|
|
2046
|
+
//
|
|
2047
|
+
// Note: backupExisting() below is a *synchronous* rename
|
|
2048
|
+
// (target -> target+".bak"); the tmpTarget -> target rename just
|
|
2049
|
+
// after it is a separate, async filesystem operation. Between
|
|
2050
|
+
// the two, target genuinely does not exist on disk for a
|
|
2051
|
+
// sub-millisecond window. Only a hard process kill (not any
|
|
2052
|
+
// catchable JS error) can land exactly there — any catchable
|
|
2053
|
+
// failure of the second rename is already handled by the
|
|
2054
|
+
// rollback block below — and recovery in that rare case is via
|
|
2055
|
+
// the surviving target+".bak" on the next forge() run (forge#2558).
|
|
2056
|
+
const backup = backupExisting(target);
|
|
2057
|
+
if (backup) effects.backedUpDelta++;
|
|
2058
|
+
try {
|
|
2059
|
+
await rename(tmpTarget, target);
|
|
2060
|
+
} catch (renameErr) {
|
|
2061
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2062
|
+
if (backup) {
|
|
2063
|
+
// The final rename failed AFTER backupExisting() already moved
|
|
2064
|
+
// the original file to target+".bak" — target no longer exists
|
|
2065
|
+
// on disk at this point. Without this rollback the user's file
|
|
2066
|
+
// would simply vanish (present only as .bak) while the WARNING
|
|
2067
|
+
// in forge() implies it merely "could not be repaired". Restore
|
|
2068
|
+
// the prior content so a failed repair is a no-op, matching the
|
|
2069
|
+
// pre-backup invariant that a rename failure never removes the
|
|
2070
|
+
// existing file (see the .tmp-write-failure test, forge#2498).
|
|
2071
|
+
// WIRE:PROVEN — manual: reasoned, not exercised by an automated
|
|
2072
|
+
// test. This branch requires rename(tmp, target) to fail on the
|
|
2073
|
+
// exact call immediately after backupExisting()'s own rename of
|
|
2074
|
+
// the same target succeeded — an OS-level race with no portable
|
|
2075
|
+
// way to force deterministically (the sibling renameErr catches
|
|
2076
|
+
// for the symlink-relink paths above, lines ~1576/~1604, are the
|
|
2077
|
+
// same kind of defensive OS-failure branch and are likewise
|
|
2078
|
+
// untested for the same reason). Verified by code inspection:
|
|
2079
|
+
// rename() is fs/promises' rename. The rollback rename's own
|
|
2080
|
+
// outcome is captured explicitly rather than assumed — if it
|
|
2081
|
+
// also fails (e.g. .bak itself is locked), target+".bak" is
|
|
2082
|
+
// left in place as the only remaining copy of the prior
|
|
2083
|
+
// content, so backedUpDelta must NOT be decremented in that
|
|
2084
|
+
// case; it correctly reflects "a backup file still exists on
|
|
2085
|
+
// disk" (forge#2559). Only on confirmed rollback success does
|
|
2086
|
+
// the decrement keep the counter consistent with the restored
|
|
2087
|
+
// on-disk state before renameErr is rethrown to the outer catch.
|
|
2088
|
+
let rollbackOk = true;
|
|
2089
|
+
await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
|
|
2090
|
+
if (rollbackOk) effects.backedUpDelta--;
|
|
2091
|
+
}
|
|
2092
|
+
throw renameErr;
|
|
2093
|
+
}
|
|
2094
|
+
recordCopy(rel);
|
|
2095
|
+
effects.updatedDelta++;
|
|
2096
|
+
effects.handled = true;
|
|
2097
|
+
}
|
|
2098
|
+
} catch { /* readFile/copyFile/rename failure — fall through to warning */ }
|
|
2099
|
+
return effects;
|
|
2100
|
+
}
|
|
1705
2101
|
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2102
|
+
/**
|
|
2103
|
+
* Per-file branch for forge(): target does not exist yet (ENOENT from
|
|
2104
|
+
* lstat) — fresh install. Symlink-first, copy-fallback; a successful link
|
|
2105
|
+
* also drops any stale manifest record for the path.
|
|
2106
|
+
* Returns counter deltas for forge() to fold in.
|
|
2107
|
+
*/
|
|
2108
|
+
async function installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete }) {
|
|
2109
|
+
let linked = false;
|
|
2110
|
+
if (wantSymlink) {
|
|
2111
|
+
linked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
2112
|
+
if (linked && manifest.files[rel]) {
|
|
2113
|
+
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
2114
|
+
recordDelete(rel);
|
|
1713
2115
|
}
|
|
1714
2116
|
}
|
|
2117
|
+
if (!linked) {
|
|
2118
|
+
// Windows without Developer Mode/admin, or symlink unreadable — copy.
|
|
2119
|
+
await copyFile(file, target);
|
|
2120
|
+
recordCopy(rel);
|
|
2121
|
+
return { installedDelta: 0, copiedDelta: 1 };
|
|
2122
|
+
}
|
|
2123
|
+
return { installedDelta: 1, copiedDelta: 0 };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* Act II receipt: render forge()'s summary lines, advisories, and hook
|
|
2128
|
+
* statuses. Pure reporting — mechanical move of the forge() tail; reads
|
|
2129
|
+
* `results` and writes to ctx.stdout, mutates nothing.
|
|
2130
|
+
*/
|
|
2131
|
+
function renderForgeReceipt(ctx, results) {
|
|
2132
|
+
const { stdout: w } = ctx;
|
|
2133
|
+
const {
|
|
2134
|
+
installed, updated, skipped, copied, backedUp, pruned, total,
|
|
2135
|
+
staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
|
|
2136
|
+
hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
|
|
2137
|
+
} = results;
|
|
1715
2138
|
|
|
1716
2139
|
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
1717
2140
|
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2141
|
+
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
1718
2142
|
const headlineDetail = copied > 0
|
|
1719
|
-
? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
|
|
1720
|
-
: `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
|
|
1721
|
-
w.write(` ${glyph(true)} ${
|
|
2143
|
+
? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
|
|
2144
|
+
: `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
|
|
2145
|
+
w.write(` ${glyph(true)} ${total} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
1722
2146
|
if (pruned > 0) {
|
|
1723
2147
|
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
1724
2148
|
}
|
|
2149
|
+
if (staleExtensionlessPruned > 0) {
|
|
2150
|
+
w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
|
|
2151
|
+
}
|
|
1725
2152
|
if (copied > 0) {
|
|
1726
2153
|
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
1727
2154
|
}
|
|
@@ -1811,8 +2238,178 @@ export async function forge(ctx) {
|
|
|
1811
2238
|
if (subagentStopEnforceStatus === "removed") {
|
|
1812
2239
|
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
1813
2240
|
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
2245
|
+
* then register the SessionStart hook.
|
|
2246
|
+
* Symlink semantics preserved verbatim from the original install():
|
|
2247
|
+
* regular files are skipped with a warning; changed links updated atomically.
|
|
2248
|
+
* On Windows without Developer Mode (symlink → EPERM/EACCES) each command is
|
|
2249
|
+
* copied instead, and recorded in a manifest so re-runs can tell ForgeDock's
|
|
2250
|
+
* own copies apart from user-owned files. ctx.linkStrategy ("symlink" default)
|
|
2251
|
+
* can be set to "copy" to skip all symlink attempts (deterministic tests).
|
|
2252
|
+
*/
|
|
2253
|
+
export async function forge(ctx) {
|
|
2254
|
+
const { stdout: w } = ctx;
|
|
2255
|
+
const commandsDir = join(ctx.forgeHome, "commands");
|
|
2256
|
+
const targetDir = join(ctx.home, ".claude", "commands");
|
|
2257
|
+
const manifestPath = join(ctx.home, ".claude", "forgedock", "copied-commands.json");
|
|
2258
|
+
const wantSymlink = ctx.linkStrategy !== "copy";
|
|
2259
|
+
|
|
2260
|
+
w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
|
|
2261
|
+
await mkdir(targetDir, { recursive: true });
|
|
2262
|
+
|
|
2263
|
+
// Clear stale extensionless entries from prior `npx forgedock` runs before
|
|
2264
|
+
// the install loop below can collide with them (forge#2620).
|
|
2265
|
+
const staleExtensionlessPruned = await pruneStaleExtensionlessEntries(targetDir);
|
|
2266
|
+
|
|
2267
|
+
const manifest = await loadCopiedManifest(manifestPath);
|
|
2268
|
+
// Reclaim crash-orphaned pid-suffixed tmp siblings left by prior hard-killed
|
|
2269
|
+
// runs (forge#2612). Best-effort — must never abort the forge receipt/hook.
|
|
2270
|
+
await sweepStaleManifestTmps(manifestPath).catch(() => {});
|
|
2271
|
+
let manifestChanged = false;
|
|
2272
|
+
// This run's own manifest mutations, tracked as a replayable diff
|
|
2273
|
+
// (forge#2614). #2599/#2609 fixed the tmp-file *write-path* race (two
|
|
2274
|
+
// concurrent forge() runs no longer collide on the same tmp filename) but
|
|
2275
|
+
// left the manifest's logical *content* racy: each run loads the manifest
|
|
2276
|
+
// into memory once, mutates its own copy across the whole run, then writes
|
|
2277
|
+
// the entire in-memory object back at the end — a classic last-writer-wins
|
|
2278
|
+
// race on manifest.files. Recording this run's own adds/deletes here (instead
|
|
2279
|
+
// of relying on the run-start in-memory snapshot) lets the final save re-read
|
|
2280
|
+
// the on-disk manifest and replay only these ops onto it, so a concurrent
|
|
2281
|
+
// run's own adds/deletes made after this run's initial load are preserved
|
|
2282
|
+
// rather than silently overwritten.
|
|
2283
|
+
const manifestOps = [];
|
|
2284
|
+
|
|
2285
|
+
const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
|
|
2286
|
+
let installed = 0, updated = 0, skipped = 0, copied = 0, backedUp = 0;
|
|
2287
|
+
const barWidth = 24;
|
|
2288
|
+
let barShown = false;
|
|
2289
|
+
|
|
2290
|
+
const recordCopy = (rel) => {
|
|
2291
|
+
if (!manifest.files[rel]) {
|
|
2292
|
+
manifest.files[rel] = true;
|
|
2293
|
+
manifestChanged = true;
|
|
2294
|
+
}
|
|
2295
|
+
manifestOps.push({ rel, op: "add" });
|
|
2296
|
+
};
|
|
2297
|
+
// Mirror of recordCopy for removals — keeps manifest.files, manifestChanged,
|
|
2298
|
+
// and the replayable manifestOps diff (forge#2614) mutating as one unit so
|
|
2299
|
+
// the in-memory manifest and the final merge-save replay never diverge.
|
|
2300
|
+
const recordDelete = (rel) => {
|
|
2301
|
+
delete manifest.files[rel];
|
|
2302
|
+
manifestChanged = true;
|
|
2303
|
+
manifestOps.push({ rel, op: "delete" });
|
|
2304
|
+
};
|
|
2305
|
+
|
|
2306
|
+
for (let i = 0; i < files.length; i++) {
|
|
2307
|
+
const file = files[i];
|
|
2308
|
+
const rel = relative(commandsDir, file);
|
|
2309
|
+
const target = join(targetDir, rel);
|
|
2310
|
+
await mkdir(pathDirname(target), { recursive: true });
|
|
2311
|
+
|
|
2312
|
+
try {
|
|
2313
|
+
const stats = await lstat(target);
|
|
2314
|
+
if (stats.isSymbolicLink()) {
|
|
2315
|
+
const current = await readlink(target);
|
|
2316
|
+
if (current === file) {
|
|
2317
|
+
skipped++;
|
|
2318
|
+
} else {
|
|
2319
|
+
let relinked = false;
|
|
2320
|
+
if (wantSymlink) {
|
|
2321
|
+
relinked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
2322
|
+
if (relinked) updated++;
|
|
2323
|
+
}
|
|
2324
|
+
if (!relinked) {
|
|
2325
|
+
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
2326
|
+
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
2327
|
+
// (On "unreadable" the rename never happened so the old link is
|
|
2328
|
+
// still here; on "denied" likewise. ENOENT tolerated defensively
|
|
2329
|
+
// in case the entry vanished out from under us — forge#2679.)
|
|
2330
|
+
await unlink(target).catch((err) => {
|
|
2331
|
+
if (err.code !== "ENOENT") throw err;
|
|
2332
|
+
});
|
|
2333
|
+
await copyFile(file, target);
|
|
2334
|
+
updated++; // replaces an existing managed entry
|
|
2335
|
+
recordCopy(rel);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
} else if (manifest.files[rel]) {
|
|
2339
|
+
const eff = await upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete });
|
|
2340
|
+
updated += eff.updatedDelta;
|
|
2341
|
+
skipped += eff.skippedDelta;
|
|
2342
|
+
} else {
|
|
2343
|
+
const eff = await adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy });
|
|
2344
|
+
updated += eff.updatedDelta;
|
|
2345
|
+
skipped += eff.skippedDelta;
|
|
2346
|
+
backedUp += eff.backedUpDelta;
|
|
2347
|
+
if (!eff.handled) {
|
|
2348
|
+
// Terminal/progress-bar state stays here — helpers never touch
|
|
2349
|
+
// barShown or write to the stream.
|
|
2350
|
+
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
2351
|
+
w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
|
|
2352
|
+
skipped++;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
} catch (err) {
|
|
2356
|
+
if (err.code !== "ENOENT") throw err;
|
|
2357
|
+
const eff = await installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete });
|
|
2358
|
+
installed += eff.installedDelta;
|
|
2359
|
+
copied += eff.copiedDelta;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (ctx.motion) {
|
|
2363
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
2364
|
+
w.write(` ${moltenBar(i + 1, files.length, { width: barWidth, mode: ctx.mode })} ${i + 1}/${files.length} ${dimLine(ctx, "/" + rel.replace(/\.md$/, ""))}\n`);
|
|
2365
|
+
barShown = true;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
2369
|
+
|
|
2370
|
+
// Prune orphaned symlinks: links that point into commandsDir but whose
|
|
2371
|
+
// target file no longer exists (e.g. after a command is renamed or deleted).
|
|
2372
|
+
const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
|
|
2373
|
+
|
|
2374
|
+
// Link the small set of universal pipeline-agent scripts (forge#1885).
|
|
2375
|
+
const scriptsResult = await linkPipelineScripts(ctx);
|
|
2376
|
+
|
|
2377
|
+
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
2378
|
+
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
2379
|
+
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
2380
|
+
|
|
2381
|
+
// Install enforcement hooks (#1250): PreToolUse (branch/label validation)
|
|
2382
|
+
// and SubagentStop interactive engine adapter. Both are idempotent and
|
|
2383
|
+
// always installed (fail-open if settings.json is malformed, same
|
|
2384
|
+
// contract as SessionStart hook).
|
|
2385
|
+
const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
|
|
2386
|
+
const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
|
|
2387
|
+
const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
|
|
2388
|
+
installSubagentStopHook(settingsPath, subagentStopScript);
|
|
2389
|
+
|
|
2390
|
+
// SubagentStop annotation-verifier hook (#1250) is NOT installed: its
|
|
2391
|
+
// trigger condition (a `FORGE:PHASE_START` marker in the transcript) is
|
|
2392
|
+
// never emitted anywhere in the pipeline, so it always exits 0 with zero
|
|
2393
|
+
// enforcement effect while still spawning a process + reading the
|
|
2394
|
+
// transcript on every SubagentStop (forge#1527). Actively clean up any
|
|
2395
|
+
// prior installation instead of installing it.
|
|
2396
|
+
const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
|
|
2397
|
+
|
|
2398
|
+
// Housekeeping — must never abort the receipt or the hook.
|
|
2399
|
+
// saveManifestWithMerge() owns the locked re-read → replay-manifestOps →
|
|
2400
|
+
// save sequence (forge#2614/forge#2637) and never throws.
|
|
2401
|
+
let manifestSaveFailed = false;
|
|
2402
|
+
if (manifestChanged) {
|
|
2403
|
+
manifestSaveFailed = !(await saveManifestWithMerge(manifestPath, manifestOps));
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
renderForgeReceipt(ctx, {
|
|
2407
|
+
installed, updated, skipped, copied, backedUp, pruned, total: files.length,
|
|
2408
|
+
staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
|
|
2409
|
+
hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
|
|
2410
|
+
});
|
|
1814
2411
|
|
|
1815
|
-
return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
2412
|
+
return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
1816
2413
|
}
|
|
1817
2414
|
|
|
1818
2415
|
// ---------------------------------------------------------------------------
|