forgedock 1.4.0 → 1.5.0
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 +65 -4
- package/bin/hooks/pre-tool-use.mjs +29 -7
- package/bin/journey.mjs +578 -42
- 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/remediate.md +53 -4
- package/package.json +1 -1
- package/packages/protocol/src/validate.js +24 -1
- 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 +184 -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,211 @@ 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
|
+
|
|
864
1063
|
/**
|
|
865
1064
|
* Allowlist of universal pipeline-agent scripts installed to
|
|
866
1065
|
* ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
|
|
@@ -941,6 +1140,108 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
|
|
|
941
1140
|
return pruned;
|
|
942
1141
|
}
|
|
943
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* Remove leftover extensionless entries at the top level of targetDir left
|
|
1145
|
+
* behind by prior `npx forgedock <command>` runs (forge#2620). Those older
|
|
1146
|
+
* ephemeral invocations created extensionless symlinks/directories (e.g.
|
|
1147
|
+
* `orchestrate`, `pipeline-health`) pointing into the npx download cache
|
|
1148
|
+
* (`%LocalAppData%\npm-cache\_npx\...` / `~/.npm/_npx/...`). Those cache
|
|
1149
|
+
* targets are routinely evicted by npx/npm cache cleanup or OS temp
|
|
1150
|
+
* cleanup, leaving dead entries that collide on the base command name with
|
|
1151
|
+
* the real `{name}.md` file this installer is about to create —
|
|
1152
|
+
* `mkdir(..., {recursive:true})` throws when a same-named non-directory
|
|
1153
|
+
* file already occupies that path segment.
|
|
1154
|
+
*
|
|
1155
|
+
* Conservative by design — must run BEFORE the main install loop so stale
|
|
1156
|
+
* entries are cleared before any mkdir/symlink attempt can collide with
|
|
1157
|
+
* them this run (pruneOrphanedSymlinks above only catches entries that
|
|
1158
|
+
* point *into commandsDir*, and runs after the loop; this catches the
|
|
1159
|
+
* separate npx-cache-origin case pre-emptively). Only removes a top-level
|
|
1160
|
+
* entry when its basename has no file extension (`.md` files and dotfiles
|
|
1161
|
+
* are never touched) AND it is a symlink whose target either no longer
|
|
1162
|
+
* exists or resolves into a known ephemeral cache path
|
|
1163
|
+
* (`isEphemeralCachePath`). Real command directories (e.g. `work-on/`)
|
|
1164
|
+
* created by this installer are plain directories containing `.md` files,
|
|
1165
|
+
* never symlinks — untouched by either condition.
|
|
1166
|
+
*
|
|
1167
|
+
* @param {string} targetDir - ~/.claude/commands
|
|
1168
|
+
* @returns {Promise<number>} Number of stale entries removed.
|
|
1169
|
+
*/
|
|
1170
|
+
// Matches a Windows drive-relative path like "C:foo" (drive letter + colon,
|
|
1171
|
+
// NOT followed by a path separator) — as opposed to a drive-absolute path
|
|
1172
|
+
// like "C:\foo" or "C:/foo", which path.isAbsolute() already recognizes
|
|
1173
|
+
// correctly. A drive-relative target resolves relative to that drive's own
|
|
1174
|
+
// current working directory, which Node has no API to query — there is no
|
|
1175
|
+
// way to compute the correct absolute path here (review finding on PR #2658,
|
|
1176
|
+
// forge#2659). Deliberately narrow: only fires when isAbsolute() has already
|
|
1177
|
+
// said "no" and the string still starts with `<letter>:` immediately
|
|
1178
|
+
// followed by a non-separator character. Platform-gated (review finding
|
|
1179
|
+
// forge#2663): on POSIX, `:` has no special meaning in filenames, so a
|
|
1180
|
+
// literal relative target like `C:foo` is a perfectly valid path component
|
|
1181
|
+
// and must NOT be misclassified as an unresolvable Windows drive-relative
|
|
1182
|
+
// path — only apply this heuristic on win32.
|
|
1183
|
+
const WINDOWS_DRIVE_RELATIVE_RE = /^[A-Za-z]:(?![\\/])/;
|
|
1184
|
+
|
|
1185
|
+
export async function pruneStaleExtensionlessEntries(targetDir) {
|
|
1186
|
+
let pruned = 0;
|
|
1187
|
+
let entries;
|
|
1188
|
+
try {
|
|
1189
|
+
entries = await readdir(targetDir, { withFileTypes: true });
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
if (err.code === "ENOENT") return pruned;
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
1194
|
+
for (const entry of entries) {
|
|
1195
|
+
const name = entry.name;
|
|
1196
|
+
if (name.includes(".")) continue; // has an extension (or is a dotfile) — never touch
|
|
1197
|
+
if (!entry.isSymbolicLink()) continue; // only manage symlinks here — real dirs/files are left alone
|
|
1198
|
+
const full = join(targetDir, name);
|
|
1199
|
+
let linkTarget;
|
|
1200
|
+
try {
|
|
1201
|
+
linkTarget = await readlink(full);
|
|
1202
|
+
} catch {
|
|
1203
|
+
continue; // unreadable link metadata — skip rather than guess
|
|
1204
|
+
}
|
|
1205
|
+
// readlink() returns whatever string was stored at symlink-creation time.
|
|
1206
|
+
// A relative target is defined (POSIX) relative to the symlink's own
|
|
1207
|
+
// containing directory (targetDir, since full = join(targetDir, name)) —
|
|
1208
|
+
// not the process cwd, which is what a raw lstat(linkTarget) would use.
|
|
1209
|
+
//
|
|
1210
|
+
// Two shapes isAbsolute() alone misclassifies as "relative" and would
|
|
1211
|
+
// otherwise be wrongly joined onto targetDir:
|
|
1212
|
+
// - Windows drive-relative ("C:foo") — see WINDOWS_DRIVE_RELATIVE_RE
|
|
1213
|
+
// above; unresolvable here, so the entry is left untouched. Only
|
|
1214
|
+
// meaningful on win32 (review finding forge#2663) — on POSIX, `:` is
|
|
1215
|
+
// just an ordinary filename character, so this check is skipped
|
|
1216
|
+
// there and the target falls through to normal relative resolution.
|
|
1217
|
+
// - Shell-style tilde ("~/foo", "~\foo", or bare "~") — expand against
|
|
1218
|
+
// os.homedir() first, matching shell semantics (review finding on
|
|
1219
|
+
// PR #2658, forge#2660).
|
|
1220
|
+
if (process.platform === "win32" && WINDOWS_DRIVE_RELATIVE_RE.test(linkTarget)) continue; // can't resolve without the drive's own cwd — leave alone
|
|
1221
|
+
let effectiveTarget = linkTarget;
|
|
1222
|
+
if (effectiveTarget === "~" || effectiveTarget.startsWith("~/") || effectiveTarget.startsWith("~\\")) {
|
|
1223
|
+
effectiveTarget = join(os.homedir(), effectiveTarget.slice(1));
|
|
1224
|
+
}
|
|
1225
|
+
const resolvedTarget = isAbsolute(effectiveTarget) ? effectiveTarget : join(targetDir, effectiveTarget);
|
|
1226
|
+
let targetMissing = false;
|
|
1227
|
+
try {
|
|
1228
|
+
await lstat(resolvedTarget);
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
|
|
1231
|
+
targetMissing = true;
|
|
1232
|
+
}
|
|
1233
|
+
if (targetMissing || isEphemeralCachePath(linkTarget)) {
|
|
1234
|
+
try {
|
|
1235
|
+
await unlink(full);
|
|
1236
|
+
pruned++;
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
if (err.code !== "ENOENT") throw err;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return pruned;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
944
1245
|
/**
|
|
945
1246
|
* Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
|
|
946
1247
|
* directory rather than a durable install location (global npm install,
|
|
@@ -1378,9 +1679,24 @@ async function linkPipelineScripts(ctx) {
|
|
|
1378
1679
|
|
|
1379
1680
|
let linked = false;
|
|
1380
1681
|
if (wantSymlink) {
|
|
1682
|
+
// Suffix the tmp sibling with this process's PID so a losing
|
|
1683
|
+
// concurrent forge() invocation linking the same brand-new pipeline
|
|
1684
|
+
// script never collides on the same literal target path (which
|
|
1685
|
+
// raises an uncaught EEXIST — isLinkPermissionError() only treats
|
|
1686
|
+
// EPERM/EACCES as recoverable). Mirrors the fresh-install symlink
|
|
1687
|
+
// branch in forge() (forge#2631) and the relink/upgrade branches
|
|
1688
|
+
// (forge#2542, forge#2600, forge#2599).
|
|
1689
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1381
1690
|
try {
|
|
1382
|
-
await symlink(file,
|
|
1383
|
-
|
|
1691
|
+
await symlink(file, tmpTarget);
|
|
1692
|
+
try {
|
|
1693
|
+
await rename(tmpTarget, target);
|
|
1694
|
+
} catch (renameErr) {
|
|
1695
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1696
|
+
throw renameErr;
|
|
1697
|
+
}
|
|
1698
|
+
linked = await isSymlinkTraversable(target);
|
|
1699
|
+
if (!linked) await unlink(target).catch(() => {});
|
|
1384
1700
|
} catch (linkErr) {
|
|
1385
1701
|
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1386
1702
|
}
|
|
@@ -1539,11 +1855,30 @@ export async function forge(ctx) {
|
|
|
1539
1855
|
w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
|
|
1540
1856
|
await mkdir(targetDir, { recursive: true });
|
|
1541
1857
|
|
|
1858
|
+
// Clear stale extensionless entries from prior `npx forgedock` runs before
|
|
1859
|
+
// the install loop below can collide with them (forge#2620).
|
|
1860
|
+
const staleExtensionlessPruned = await pruneStaleExtensionlessEntries(targetDir);
|
|
1861
|
+
|
|
1542
1862
|
const manifest = await loadCopiedManifest(manifestPath);
|
|
1863
|
+
// Reclaim crash-orphaned pid-suffixed tmp siblings left by prior hard-killed
|
|
1864
|
+
// runs (forge#2612). Best-effort — must never abort the forge receipt/hook.
|
|
1865
|
+
await sweepStaleManifestTmps(manifestPath).catch(() => {});
|
|
1543
1866
|
let manifestChanged = false;
|
|
1867
|
+
// This run's own manifest mutations, tracked as a replayable diff
|
|
1868
|
+
// (forge#2614). #2599/#2609 fixed the tmp-file *write-path* race (two
|
|
1869
|
+
// concurrent forge() runs no longer collide on the same tmp filename) but
|
|
1870
|
+
// left the manifest's logical *content* racy: each run loads the manifest
|
|
1871
|
+
// into memory once, mutates its own copy across the whole run, then writes
|
|
1872
|
+
// the entire in-memory object back at the end — a classic last-writer-wins
|
|
1873
|
+
// race on manifest.files. Recording this run's own adds/deletes here (instead
|
|
1874
|
+
// of relying on the run-start in-memory snapshot) lets the final save re-read
|
|
1875
|
+
// the on-disk manifest and replay only these ops onto it, so a concurrent
|
|
1876
|
+
// run's own adds/deletes made after this run's initial load are preserved
|
|
1877
|
+
// rather than silently overwritten.
|
|
1878
|
+
const manifestOps = [];
|
|
1544
1879
|
|
|
1545
1880
|
const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
|
|
1546
|
-
let installed = 0, updated = 0, skipped = 0, copied = 0;
|
|
1881
|
+
let installed = 0, updated = 0, skipped = 0, copied = 0, backedUp = 0;
|
|
1547
1882
|
const barWidth = 24;
|
|
1548
1883
|
let barShown = false;
|
|
1549
1884
|
|
|
@@ -1552,6 +1887,7 @@ export async function forge(ctx) {
|
|
|
1552
1887
|
manifest.files[rel] = true;
|
|
1553
1888
|
manifestChanged = true;
|
|
1554
1889
|
}
|
|
1890
|
+
manifestOps.push({ rel, op: "add" });
|
|
1555
1891
|
};
|
|
1556
1892
|
|
|
1557
1893
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -1569,16 +1905,31 @@ export async function forge(ctx) {
|
|
|
1569
1905
|
} else {
|
|
1570
1906
|
let relinked = false;
|
|
1571
1907
|
if (wantSymlink) {
|
|
1908
|
+
// Suffix the tmp sibling with this process's PID so a losing
|
|
1909
|
+
// concurrent forge() invocation's symlink() call never collides
|
|
1910
|
+
// on the same literal path (which raises an uncaught EEXIST —
|
|
1911
|
+
// isLinkPermissionError() only treats EPERM/EACCES as
|
|
1912
|
+
// recoverable). Mirrors the copyFile branch and
|
|
1913
|
+
// saveCopiedManifest fixes already applied in this file
|
|
1914
|
+
// (forge#2542, forge#2599).
|
|
1915
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1572
1916
|
try {
|
|
1573
|
-
await symlink(file,
|
|
1917
|
+
await symlink(file, tmpTarget);
|
|
1574
1918
|
try {
|
|
1575
|
-
await rename(
|
|
1919
|
+
await rename(tmpTarget, target);
|
|
1576
1920
|
} catch (renameErr) {
|
|
1577
|
-
await unlink(
|
|
1921
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1578
1922
|
throw renameErr;
|
|
1579
1923
|
}
|
|
1580
|
-
|
|
1581
|
-
relinked
|
|
1924
|
+
relinked = await isSymlinkTraversable(target);
|
|
1925
|
+
if (relinked) {
|
|
1926
|
+
updated++;
|
|
1927
|
+
} else {
|
|
1928
|
+
// Symlink was created but is not readable back (e.g. MSYS
|
|
1929
|
+
// symlink hit by native Windows "untrusted mount point" —
|
|
1930
|
+
// forge#2620). Remove it and fall through to the copy branch.
|
|
1931
|
+
await unlink(target).catch(() => {});
|
|
1932
|
+
}
|
|
1582
1933
|
} catch (linkErr) {
|
|
1583
1934
|
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1584
1935
|
}
|
|
@@ -1586,7 +1937,11 @@ export async function forge(ctx) {
|
|
|
1586
1937
|
if (!relinked) {
|
|
1587
1938
|
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
1588
1939
|
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
1589
|
-
|
|
1940
|
+
// (target may already be gone here if isSymlinkTraversable() failed
|
|
1941
|
+
// and its own cleanup already removed it — tolerate ENOENT.)
|
|
1942
|
+
await unlink(target).catch((err) => {
|
|
1943
|
+
if (err.code !== "ENOENT") throw err;
|
|
1944
|
+
});
|
|
1590
1945
|
await copyFile(file, target);
|
|
1591
1946
|
updated++; // replaces an existing managed entry
|
|
1592
1947
|
recordCopy(rel);
|
|
@@ -1596,24 +1951,43 @@ export async function forge(ctx) {
|
|
|
1596
1951
|
// A regular file we copied on a previous run — ours to manage.
|
|
1597
1952
|
// First try upgrading it to a symlink (Developer Mode enabled since).
|
|
1598
1953
|
let upgraded = false;
|
|
1954
|
+
let restoredAsCopy = false;
|
|
1599
1955
|
if (wantSymlink) {
|
|
1956
|
+
// Same per-process-unique tmp suffix as the relink branch above —
|
|
1957
|
+
// sibling branch, identical shared-literal-path EEXIST risk
|
|
1958
|
+
// (forge#2542, forge#2599).
|
|
1959
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1600
1960
|
try {
|
|
1601
|
-
await symlink(file,
|
|
1961
|
+
await symlink(file, tmpTarget);
|
|
1602
1962
|
try {
|
|
1603
|
-
await rename(
|
|
1963
|
+
await rename(tmpTarget, target); // rename replaces existing files on Windows
|
|
1604
1964
|
} catch (renameErr) {
|
|
1605
|
-
await unlink(
|
|
1965
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1606
1966
|
throw renameErr;
|
|
1607
1967
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1968
|
+
upgraded = await isSymlinkTraversable(target);
|
|
1969
|
+
if (upgraded) {
|
|
1970
|
+
updated++;
|
|
1971
|
+
delete manifest.files[rel];
|
|
1972
|
+
manifestChanged = true;
|
|
1973
|
+
manifestOps.push({ rel, op: "delete" });
|
|
1974
|
+
} else {
|
|
1975
|
+
// Symlink was created but is not readable back (e.g. MSYS
|
|
1976
|
+
// symlink hit by native Windows "untrusted mount point" —
|
|
1977
|
+
// forge#2620). Restore a real copy so the command stays
|
|
1978
|
+
// readable. Keep the manifest entry (still copy-managed, not
|
|
1979
|
+
// symlink-managed) and do NOT fall through to the generic
|
|
1980
|
+
// copy-comparison below — the copy is already done.
|
|
1981
|
+
await unlink(target).catch(() => {});
|
|
1982
|
+
await copyFile(file, target);
|
|
1983
|
+
updated++;
|
|
1984
|
+
restoredAsCopy = true;
|
|
1985
|
+
}
|
|
1612
1986
|
} catch (linkErr) {
|
|
1613
1987
|
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1614
1988
|
}
|
|
1615
1989
|
}
|
|
1616
|
-
if (!upgraded) {
|
|
1990
|
+
if (!upgraded && !restoredAsCopy) {
|
|
1617
1991
|
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1618
1992
|
if (src.equals(dst)) {
|
|
1619
1993
|
skipped++;
|
|
@@ -1623,22 +1997,127 @@ export async function forge(ctx) {
|
|
|
1623
1997
|
}
|
|
1624
1998
|
}
|
|
1625
1999
|
} else {
|
|
1626
|
-
// Regular file not in manifest —
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
|
|
2000
|
+
// Regular file not in manifest — a copy from a pre-manifest ForgeDock
|
|
2001
|
+
// version, or a stale copy that was never adopted. `rel` here is
|
|
2002
|
+
// always enumerated from `files` (ForgeDock's own commandsDir
|
|
2003
|
+
// listing, see above) — every path this loop touches is a
|
|
2004
|
+
// ForgeDock-managed command file, never an arbitrary user file, so
|
|
2005
|
+
// there is no "user-customized" case to protect. Content-compare: if
|
|
2006
|
+
// it matches source, adopt it into the manifest silently (it was
|
|
2007
|
+
// ours all along). If it differs, it's stale — overwrite it and
|
|
2008
|
+
// adopt it into the manifest too, mirroring the manifest-tracked
|
|
2009
|
+
// branch above, so future runs take the manifest-aware fast path.
|
|
2010
|
+
let handled = false;
|
|
1631
2011
|
try {
|
|
1632
2012
|
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1633
2013
|
if (src.equals(dst)) {
|
|
1634
2014
|
recordCopy(rel);
|
|
1635
2015
|
skipped++;
|
|
1636
|
-
|
|
2016
|
+
handled = true;
|
|
2017
|
+
} else {
|
|
2018
|
+
// Write to a .tmp sibling and atomically rename onto target — never
|
|
2019
|
+
// open target itself for a direct overwrite. copyFile() is not
|
|
2020
|
+
// atomic: a mid-write failure (AV lock / ENOSPC / process kill)
|
|
2021
|
+
// would otherwise leave target truncated/corrupted rather than
|
|
2022
|
+
// simply stale, which the WARNING below cannot distinguish. This
|
|
2023
|
+
// mirrors the .tmp+rename swap already used for symlink installs
|
|
2024
|
+
// above (lines ~1573-1579, ~1601-1607).
|
|
2025
|
+
//
|
|
2026
|
+
// Unlike those symlink-based branches — where symlink() is itself
|
|
2027
|
+
// exclusive-create and therefore naturally races out a concurrent
|
|
2028
|
+
// writer — copyFile() has no such semantics: two concurrent
|
|
2029
|
+
// forge() invocations (e.g. an overlapping `doctor --fix` run, or
|
|
2030
|
+
// a file watcher) targeting the same file would both write
|
|
2031
|
+
// through the same shared `target + ".tmp"` path and could
|
|
2032
|
+
// interleave or clobber each other before either rename() runs.
|
|
2033
|
+
// Suffix the tmp sibling with this process's PID so concurrent
|
|
2034
|
+
// invocations never share a path (forge#2542).
|
|
2035
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
2036
|
+
try {
|
|
2037
|
+
await copyFile(file, tmpTarget);
|
|
2038
|
+
} catch (copyErr) {
|
|
2039
|
+
// A copyFile failure (disk full mid-copy, AV lock, permission
|
|
2040
|
+
// error) can still leave a partially-written .tmp sibling on
|
|
2041
|
+
// disk even though the write itself threw. The sibling
|
|
2042
|
+
// rename() failure just below already cleans up tmpTarget
|
|
2043
|
+
// on its own failure path (renameErr) — mirror that here so a
|
|
2044
|
+
// copyFile failure doesn't orphan the .tmp file instead
|
|
2045
|
+
// (forge#2540). Best-effort: never let the cleanup itself mask
|
|
2046
|
+
// or replace the original copyErr being rethrown.
|
|
2047
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2048
|
+
throw copyErr;
|
|
2049
|
+
}
|
|
2050
|
+
// Preserve the pre-existing content before it's replaced, mirroring
|
|
2051
|
+
// half of the confirm+backup convention used by the forge.yaml
|
|
2052
|
+
// overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
|
|
2053
|
+
// review(), both via backupExisting()). A confirmation prompt is
|
|
2054
|
+
// deliberately NOT added: this branch also runs non-interactively
|
|
2055
|
+
// inside `doctor --fix`'s automated repair (bin/forgedock.mjs
|
|
2056
|
+
// runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
|
|
2057
|
+
// input would break that flow. Placed here — after the .tmp write
|
|
2058
|
+
// has already succeeded, before the final rename — rather than
|
|
2059
|
+
// before copyFile (as the forge.yaml path does), so a copyFile
|
|
2060
|
+
// failure never backs up (and thereby displaces) a target that's
|
|
2061
|
+
// about to remain untouched (see the .tmp-write-failure test,
|
|
2062
|
+
// forge#2498). backupExisting()'s rename is itself atomic, so this
|
|
2063
|
+
// ordering carries none of the non-atomic-write risk that forge#1396
|
|
2064
|
+
// flagged for the old backup-then-writeFileSync forge.yaml path.
|
|
2065
|
+
//
|
|
2066
|
+
// Note: backupExisting() below is a *synchronous* rename
|
|
2067
|
+
// (target -> target+".bak"); the tmpTarget -> target rename just
|
|
2068
|
+
// after it is a separate, async filesystem operation. Between
|
|
2069
|
+
// the two, target genuinely does not exist on disk for a
|
|
2070
|
+
// sub-millisecond window. Only a hard process kill (not any
|
|
2071
|
+
// catchable JS error) can land exactly there — any catchable
|
|
2072
|
+
// failure of the second rename is already handled by the
|
|
2073
|
+
// rollback block below — and recovery in that rare case is via
|
|
2074
|
+
// the surviving target+".bak" on the next forge() run (forge#2558).
|
|
2075
|
+
const backup = backupExisting(target);
|
|
2076
|
+
if (backup) backedUp++;
|
|
2077
|
+
try {
|
|
2078
|
+
await rename(tmpTarget, target);
|
|
2079
|
+
} catch (renameErr) {
|
|
2080
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2081
|
+
if (backup) {
|
|
2082
|
+
// The final rename failed AFTER backupExisting() already moved
|
|
2083
|
+
// the original file to target+".bak" — target no longer exists
|
|
2084
|
+
// on disk at this point. Without this rollback the user's file
|
|
2085
|
+
// would simply vanish (present only as .bak) while the WARNING
|
|
2086
|
+
// below implies it merely "could not be repaired". Restore the
|
|
2087
|
+
// prior content so a failed repair is a no-op, matching the
|
|
2088
|
+
// pre-backup invariant that a rename failure never removes the
|
|
2089
|
+
// existing file (see the .tmp-write-failure test, forge#2498).
|
|
2090
|
+
// WIRE:PROVEN — manual: reasoned, not exercised by an automated
|
|
2091
|
+
// test. This branch requires rename(tmp, target) to fail on the
|
|
2092
|
+
// exact call immediately after backupExisting()'s own rename of
|
|
2093
|
+
// the same target succeeded — an OS-level race with no portable
|
|
2094
|
+
// way to force deterministically (the sibling renameErr catches
|
|
2095
|
+
// for the symlink-relink paths above, lines ~1576/~1604, are the
|
|
2096
|
+
// same kind of defensive OS-failure branch and are likewise
|
|
2097
|
+
// untested for the same reason). Verified by code inspection:
|
|
2098
|
+
// rename() is fs/promises' rename. The rollback rename's own
|
|
2099
|
+
// outcome is captured explicitly rather than assumed — if it
|
|
2100
|
+
// also fails (e.g. .bak itself is locked), target+".bak" is
|
|
2101
|
+
// left in place as the only remaining copy of the prior
|
|
2102
|
+
// content, so backedUp must NOT be decremented in that case;
|
|
2103
|
+
// it correctly reflects "a backup file still exists on disk"
|
|
2104
|
+
// (forge#2559). Only on confirmed rollback success does
|
|
2105
|
+
// backedUp-- keep the counter consistent with the restored
|
|
2106
|
+
// on-disk state before renameErr is rethrown to the outer catch.
|
|
2107
|
+
let rollbackOk = true;
|
|
2108
|
+
await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
|
|
2109
|
+
if (rollbackOk) backedUp--;
|
|
2110
|
+
}
|
|
2111
|
+
throw renameErr;
|
|
2112
|
+
}
|
|
2113
|
+
recordCopy(rel);
|
|
2114
|
+
updated++;
|
|
2115
|
+
handled = true;
|
|
1637
2116
|
}
|
|
1638
|
-
} catch { /* readFile failure — fall through to warning */ }
|
|
1639
|
-
if (!
|
|
2117
|
+
} catch { /* readFile/copyFile/rename failure — fall through to warning */ }
|
|
2118
|
+
if (!handled) {
|
|
1640
2119
|
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
1641
|
-
w.write(` WARNING: ${rel}
|
|
2120
|
+
w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
|
|
1642
2121
|
skipped++;
|
|
1643
2122
|
}
|
|
1644
2123
|
}
|
|
@@ -1646,21 +2125,42 @@ export async function forge(ctx) {
|
|
|
1646
2125
|
if (err.code !== "ENOENT") throw err;
|
|
1647
2126
|
let linked = false;
|
|
1648
2127
|
if (wantSymlink) {
|
|
2128
|
+
// Suffix the tmp sibling with this process's PID so a losing
|
|
2129
|
+
// concurrent forge() invocation installing the same brand-new
|
|
2130
|
+
// command never collides on the same literal target path (which
|
|
2131
|
+
// raises an uncaught EEXIST — isLinkPermissionError() only treats
|
|
2132
|
+
// EPERM/EACCES as recoverable). Mirrors the relink and copy-upgrade
|
|
2133
|
+
// branches above (forge#2542, forge#2600, forge#2599).
|
|
2134
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1649
2135
|
try {
|
|
1650
|
-
await symlink(file,
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2136
|
+
await symlink(file, tmpTarget);
|
|
2137
|
+
try {
|
|
2138
|
+
await rename(tmpTarget, target);
|
|
2139
|
+
} catch (renameErr) {
|
|
2140
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2141
|
+
throw renameErr;
|
|
2142
|
+
}
|
|
2143
|
+
linked = await isSymlinkTraversable(target);
|
|
2144
|
+
if (linked) {
|
|
2145
|
+
installed++;
|
|
2146
|
+
if (manifest.files[rel]) {
|
|
2147
|
+
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
2148
|
+
delete manifest.files[rel];
|
|
2149
|
+
manifestChanged = true;
|
|
2150
|
+
manifestOps.push({ rel, op: "delete" });
|
|
2151
|
+
}
|
|
2152
|
+
} else {
|
|
2153
|
+
// Symlink created but not readable back (e.g. MSYS symlink hit by
|
|
2154
|
+
// native Windows "untrusted mount point" — forge#2620). Remove it
|
|
2155
|
+
// and fall through to the copy path below.
|
|
2156
|
+
await unlink(target).catch(() => {});
|
|
1657
2157
|
}
|
|
1658
2158
|
} catch (linkErr) {
|
|
1659
2159
|
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1660
2160
|
}
|
|
1661
2161
|
}
|
|
1662
2162
|
if (!linked) {
|
|
1663
|
-
// Windows without Developer Mode/admin
|
|
2163
|
+
// Windows without Developer Mode/admin, or symlink unreadable — copy.
|
|
1664
2164
|
await copyFile(file, target);
|
|
1665
2165
|
copied++;
|
|
1666
2166
|
recordCopy(rel);
|
|
@@ -1707,7 +2207,39 @@ export async function forge(ctx) {
|
|
|
1707
2207
|
let manifestSaveFailed = false;
|
|
1708
2208
|
if (manifestChanged) {
|
|
1709
2209
|
try {
|
|
1710
|
-
|
|
2210
|
+
// Re-read-and-merge (forge#2614): another concurrent forge() invocation
|
|
2211
|
+
// may have saved its own manifest.files adds/deletes since this run's
|
|
2212
|
+
// initial load above. Re-reading the on-disk manifest right before this
|
|
2213
|
+
// run's own save and replaying only this run's own ops (manifestOps)
|
|
2214
|
+
// onto that fresh copy — rather than blindly overwriting with the
|
|
2215
|
+
// run-start in-memory snapshot — means this run's save can no longer
|
|
2216
|
+
// silently drop the other run's changes. loadCopiedManifest() never
|
|
2217
|
+
// throws (missing/corrupt on-disk manifest resolves to an empty one),
|
|
2218
|
+
// so this stays within the surrounding never-abort contract.
|
|
2219
|
+
//
|
|
2220
|
+
// Lock the re-read→merge→write sequence itself (forge#2637): #2614
|
|
2221
|
+
// narrowed the race from "whole run duration" to this critical section,
|
|
2222
|
+
// but left it unprotected — a third concurrent forge() could still land
|
|
2223
|
+
// its own save inside the gap between this run's re-read and its own
|
|
2224
|
+
// write-rename completing. acquireManifestLock() is best-effort: on
|
|
2225
|
+
// failure to acquire (contention exhausted retries, or an unexpected
|
|
2226
|
+
// error) it returns null and this run proceeds unlocked, same as
|
|
2227
|
+
// before — the lock narrows the window further, it does not change the
|
|
2228
|
+
// never-abort contract.
|
|
2229
|
+
const lockHandle = await acquireManifestLock(manifestPath);
|
|
2230
|
+
try {
|
|
2231
|
+
const mergedManifest = await loadCopiedManifest(manifestPath);
|
|
2232
|
+
for (const { rel, op } of manifestOps) {
|
|
2233
|
+
if (op === "add") {
|
|
2234
|
+
mergedManifest.files[rel] = true;
|
|
2235
|
+
} else {
|
|
2236
|
+
delete mergedManifest.files[rel];
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
await saveCopiedManifest(manifestPath, mergedManifest);
|
|
2240
|
+
} finally {
|
|
2241
|
+
await releaseManifestLock(manifestPath, lockHandle);
|
|
2242
|
+
}
|
|
1711
2243
|
} catch {
|
|
1712
2244
|
manifestSaveFailed = true;
|
|
1713
2245
|
}
|
|
@@ -1715,13 +2247,17 @@ export async function forge(ctx) {
|
|
|
1715
2247
|
|
|
1716
2248
|
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
1717
2249
|
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2250
|
+
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
1718
2251
|
const headlineDetail = copied > 0
|
|
1719
|
-
? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
|
|
1720
|
-
: `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
|
|
2252
|
+
? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
|
|
2253
|
+
: `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
|
|
1721
2254
|
w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
1722
2255
|
if (pruned > 0) {
|
|
1723
2256
|
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
1724
2257
|
}
|
|
2258
|
+
if (staleExtensionlessPruned > 0) {
|
|
2259
|
+
w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
|
|
2260
|
+
}
|
|
1725
2261
|
if (copied > 0) {
|
|
1726
2262
|
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
1727
2263
|
}
|
|
@@ -1812,7 +2348,7 @@ export async function forge(ctx) {
|
|
|
1812
2348
|
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
1813
2349
|
}
|
|
1814
2350
|
|
|
1815
|
-
return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
2351
|
+
return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
1816
2352
|
}
|
|
1817
2353
|
|
|
1818
2354
|
// ---------------------------------------------------------------------------
|