forgedock 1.5.0 → 1.6.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/forgedock.mjs +72 -25
- package/bin/hooks/session-start.mjs +22 -1
- package/bin/journey.mjs +460 -399
- package/bin/registry.mjs +86 -0
- package/bin/runner.mjs +197 -34
- package/commands/orchestrate/phase-3-dependency.md +6 -1
- package/commands/orchestrate/phase-4-execution.md +113 -3
- package/commands/work-on/build/architect.md +28 -3
- package/package.json +1 -1
- package/scripts/build-knowledge-index.mjs +6 -2
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/derive-finding-milestone.sh +18 -8
package/bin/journey.mjs
CHANGED
|
@@ -1060,6 +1060,73 @@ export async function isSymlinkTraversable(target) {
|
|
|
1060
1060
|
}
|
|
1061
1061
|
}
|
|
1062
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
|
+
|
|
1063
1130
|
/**
|
|
1064
1131
|
* Allowlist of universal pipeline-agent scripts installed to
|
|
1065
1132
|
* ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
|
|
@@ -1679,27 +1746,7 @@ async function linkPipelineScripts(ctx) {
|
|
|
1679
1746
|
|
|
1680
1747
|
let linked = false;
|
|
1681
1748
|
if (wantSymlink) {
|
|
1682
|
-
|
|
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";
|
|
1690
|
-
try {
|
|
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(() => {});
|
|
1700
|
-
} catch (linkErr) {
|
|
1701
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1702
|
-
}
|
|
1749
|
+
linked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
1703
1750
|
}
|
|
1704
1751
|
if (!linked) {
|
|
1705
1752
|
await copyFile(file, target);
|
|
@@ -1835,6 +1882,364 @@ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
|
|
|
1835
1882
|
}
|
|
1836
1883
|
}
|
|
1837
1884
|
|
|
1885
|
+
/**
|
|
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.
|
|
1890
|
+
*/
|
|
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);
|
|
1913
|
+
try {
|
|
1914
|
+
const mergedManifest = await loadCopiedManifest(manifestPath);
|
|
1915
|
+
for (const { rel, op } of manifestOps) {
|
|
1916
|
+
if (op === "add") {
|
|
1917
|
+
mergedManifest.files[rel] = true;
|
|
1918
|
+
} else {
|
|
1919
|
+
delete mergedManifest.files[rel];
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
await saveCopiedManifest(manifestPath, mergedManifest);
|
|
1923
|
+
} finally {
|
|
1924
|
+
await releaseManifestLock(manifestPath, lockHandle);
|
|
1925
|
+
}
|
|
1926
|
+
return true;
|
|
1927
|
+
} catch {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
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;
|
|
1957
|
+
}
|
|
1958
|
+
// "denied" — no symlink installed; fall through to content-compare.
|
|
1959
|
+
}
|
|
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
|
+
}
|
|
1970
|
+
|
|
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
|
+
}
|
|
2101
|
+
|
|
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);
|
|
2115
|
+
}
|
|
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;
|
|
2138
|
+
|
|
2139
|
+
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
2140
|
+
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2141
|
+
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
2142
|
+
const headlineDetail = copied > 0
|
|
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`);
|
|
2146
|
+
if (pruned > 0) {
|
|
2147
|
+
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
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
|
+
}
|
|
2152
|
+
if (copied > 0) {
|
|
2153
|
+
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
2154
|
+
}
|
|
2155
|
+
if (scriptsResult.total > 0) {
|
|
2156
|
+
const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
|
|
2157
|
+
const scriptsDetail = scriptsChanged > 0
|
|
2158
|
+
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
2159
|
+
: `(unchanged ${scriptsResult.skipped})`;
|
|
2160
|
+
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
|
|
2161
|
+
}
|
|
2162
|
+
if (manifestSaveFailed) {
|
|
2163
|
+
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
2164
|
+
}
|
|
2165
|
+
if (hookStatus === "skipped-malformed") {
|
|
2166
|
+
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
2167
|
+
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
2168
|
+
} else {
|
|
2169
|
+
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
|
|
2173
|
+
// runJourney() calls persistHome(ctx) right before forge() and stashes its
|
|
2174
|
+
// result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
|
|
2175
|
+
// wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
|
|
2176
|
+
// stable ~/.forge/ copy, so report that outcome instead of the raw
|
|
2177
|
+
// ephemeral-cache warning. Direct forge() callers that never set
|
|
2178
|
+
// ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
|
|
2179
|
+
// through to the original ephemeral-cache-only check unchanged.
|
|
2180
|
+
const persistResult = ctx.persistHomeResult;
|
|
2181
|
+
if (persistResult && !persistResult.skipped) {
|
|
2182
|
+
if (persistResult.migrated) {
|
|
2183
|
+
w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
|
|
2184
|
+
} else {
|
|
2185
|
+
w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
|
|
2186
|
+
}
|
|
2187
|
+
} else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
|
|
2188
|
+
// Skipped for a reason OTHER than "still ephemeral" — today that's always
|
|
2189
|
+
// the git-working-tree exemption (Acceptance Criteria #5). Informational,
|
|
2190
|
+
// not a warning: this is the expected, correct state for a git-clone install.
|
|
2191
|
+
w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
|
|
2192
|
+
} else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
|
|
2193
|
+
// Either persistHome() was never run (no ctx.persistHomeResult), or it
|
|
2194
|
+
// ran but failed and left ctx.forgeHome pointing at the original
|
|
2195
|
+
// ephemeral source (fail-open — see persistHome()'s error branch).
|
|
2196
|
+
w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
|
|
2197
|
+
w.write(fixCard([
|
|
2198
|
+
"This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
|
|
2199
|
+
"or OS temp cleanup. If that happens, the SessionStart hook silently stops",
|
|
2200
|
+
"injecting ForgeDock context — no error is shown.",
|
|
2201
|
+
"Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
|
|
2202
|
+
], ctx.mode, "warning") + "\n");
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Cross-environment install advisory (forge#1893): warn when this repo is
|
|
2206
|
+
// reachable from both WSL and native Windows and BOTH sides already have a
|
|
2207
|
+
// ForgeDock install. Each environment's install is fully independent (see
|
|
2208
|
+
// #1589 — installs are always global, never per-repo), so running from
|
|
2209
|
+
// both silently leaves forge.yaml/hook paths pointing at whichever side ran
|
|
2210
|
+
// most recently, clobbering the other's config with no error surfaced.
|
|
2211
|
+
// Advisory only — never fails the install.
|
|
2212
|
+
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
2213
|
+
const crossEnv = detectCrossEnvInstall(ctx, envInfo);
|
|
2214
|
+
if (crossEnv.conflict) {
|
|
2215
|
+
const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
|
|
2216
|
+
w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
|
|
2217
|
+
w.write(fixCard([
|
|
2218
|
+
`This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
|
|
2219
|
+
"install already exists. Each side's install is independent — running",
|
|
2220
|
+
"forgedock from both can leave forge.yaml and hook paths pointing at",
|
|
2221
|
+
"whichever environment ran last, silently overwriting the other's config.",
|
|
2222
|
+
"Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
|
|
2223
|
+
], ctx.mode, "warning") + "\n");
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// Report enforcement hook status.
|
|
2227
|
+
if (preToolUseStatus !== null) {
|
|
2228
|
+
w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
|
|
2229
|
+
} else {
|
|
2230
|
+
w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
|
|
2231
|
+
}
|
|
2232
|
+
// "skipped-malformed" and "absent" are intentionally silent here:
|
|
2233
|
+
// - skipped-malformed: installSessionStartHook() above reads the same
|
|
2234
|
+
// settingsPath and already prints the malformed-JSON fix-card, so a
|
|
2235
|
+
// second message would just repeat the same signal.
|
|
2236
|
+
// - absent: the expected steady state (nothing to remove) — this
|
|
2237
|
+
// function only reports state *changes*, not no-ops.
|
|
2238
|
+
if (subagentStopEnforceStatus === "removed") {
|
|
2239
|
+
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1838
2243
|
/**
|
|
1839
2244
|
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
1840
2245
|
* then register the SessionStart hook.
|
|
@@ -1889,6 +2294,14 @@ export async function forge(ctx) {
|
|
|
1889
2294
|
}
|
|
1890
2295
|
manifestOps.push({ rel, op: "add" });
|
|
1891
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
|
+
};
|
|
1892
2305
|
|
|
1893
2306
|
for (let i = 0; i < files.length; i++) {
|
|
1894
2307
|
const file = files[i];
|
|
@@ -1905,40 +2318,15 @@ export async function forge(ctx) {
|
|
|
1905
2318
|
} else {
|
|
1906
2319
|
let relinked = false;
|
|
1907
2320
|
if (wantSymlink) {
|
|
1908
|
-
|
|
1909
|
-
|
|
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";
|
|
1916
|
-
try {
|
|
1917
|
-
await symlink(file, tmpTarget);
|
|
1918
|
-
try {
|
|
1919
|
-
await rename(tmpTarget, target);
|
|
1920
|
-
} catch (renameErr) {
|
|
1921
|
-
await unlink(tmpTarget).catch(() => {});
|
|
1922
|
-
throw renameErr;
|
|
1923
|
-
}
|
|
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
|
-
}
|
|
1933
|
-
} catch (linkErr) {
|
|
1934
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1935
|
-
}
|
|
2321
|
+
relinked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
2322
|
+
if (relinked) updated++;
|
|
1936
2323
|
}
|
|
1937
2324
|
if (!relinked) {
|
|
1938
2325
|
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
1939
2326
|
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
1940
|
-
// (
|
|
1941
|
-
//
|
|
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.)
|
|
1942
2330
|
await unlink(target).catch((err) => {
|
|
1943
2331
|
if (err.code !== "ENOENT") throw err;
|
|
1944
2332
|
});
|
|
@@ -1948,174 +2336,17 @@ export async function forge(ctx) {
|
|
|
1948
2336
|
}
|
|
1949
2337
|
}
|
|
1950
2338
|
} else if (manifest.files[rel]) {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
let restoredAsCopy = false;
|
|
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";
|
|
1960
|
-
try {
|
|
1961
|
-
await symlink(file, tmpTarget);
|
|
1962
|
-
try {
|
|
1963
|
-
await rename(tmpTarget, target); // rename replaces existing files on Windows
|
|
1964
|
-
} catch (renameErr) {
|
|
1965
|
-
await unlink(tmpTarget).catch(() => {});
|
|
1966
|
-
throw renameErr;
|
|
1967
|
-
}
|
|
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
|
-
}
|
|
1986
|
-
} catch (linkErr) {
|
|
1987
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
if (!upgraded && !restoredAsCopy) {
|
|
1991
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1992
|
-
if (src.equals(dst)) {
|
|
1993
|
-
skipped++;
|
|
1994
|
-
} else {
|
|
1995
|
-
await copyFile(file, target);
|
|
1996
|
-
updated++;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
2339
|
+
const eff = await upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete });
|
|
2340
|
+
updated += eff.updatedDelta;
|
|
2341
|
+
skipped += eff.skippedDelta;
|
|
1999
2342
|
} else {
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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;
|
|
2011
|
-
try {
|
|
2012
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
2013
|
-
if (src.equals(dst)) {
|
|
2014
|
-
recordCopy(rel);
|
|
2015
|
-
skipped++;
|
|
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;
|
|
2116
|
-
}
|
|
2117
|
-
} catch { /* readFile/copyFile/rename failure — fall through to warning */ }
|
|
2118
|
-
if (!handled) {
|
|
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.
|
|
2119
2350
|
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
2120
2351
|
w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
|
|
2121
2352
|
skipped++;
|
|
@@ -2123,48 +2354,9 @@ export async function forge(ctx) {
|
|
|
2123
2354
|
}
|
|
2124
2355
|
} catch (err) {
|
|
2125
2356
|
if (err.code !== "ENOENT") throw err;
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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";
|
|
2135
|
-
try {
|
|
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(() => {});
|
|
2157
|
-
}
|
|
2158
|
-
} catch (linkErr) {
|
|
2159
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
2160
|
-
}
|
|
2161
|
-
}
|
|
2162
|
-
if (!linked) {
|
|
2163
|
-
// Windows without Developer Mode/admin, or symlink unreadable — copy.
|
|
2164
|
-
await copyFile(file, target);
|
|
2165
|
-
copied++;
|
|
2166
|
-
recordCopy(rel);
|
|
2167
|
-
}
|
|
2357
|
+
const eff = await installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete });
|
|
2358
|
+
installed += eff.installedDelta;
|
|
2359
|
+
copied += eff.copiedDelta;
|
|
2168
2360
|
}
|
|
2169
2361
|
|
|
2170
2362
|
if (ctx.motion) {
|
|
@@ -2204,149 +2396,18 @@ export async function forge(ctx) {
|
|
|
2204
2396
|
const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
|
|
2205
2397
|
|
|
2206
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.
|
|
2207
2401
|
let manifestSaveFailed = false;
|
|
2208
2402
|
if (manifestChanged) {
|
|
2209
|
-
|
|
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
|
-
}
|
|
2243
|
-
} catch {
|
|
2244
|
-
manifestSaveFailed = true;
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
2249
|
-
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2250
|
-
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
2251
|
-
const headlineDetail = copied > 0
|
|
2252
|
-
? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
|
|
2253
|
-
: `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
|
|
2254
|
-
w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
2255
|
-
if (pruned > 0) {
|
|
2256
|
-
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
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
|
-
}
|
|
2261
|
-
if (copied > 0) {
|
|
2262
|
-
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
2263
|
-
}
|
|
2264
|
-
if (scriptsResult.total > 0) {
|
|
2265
|
-
const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
|
|
2266
|
-
const scriptsDetail = scriptsChanged > 0
|
|
2267
|
-
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
2268
|
-
: `(unchanged ${scriptsResult.skipped})`;
|
|
2269
|
-
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
|
|
2270
|
-
}
|
|
2271
|
-
if (manifestSaveFailed) {
|
|
2272
|
-
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
2273
|
-
}
|
|
2274
|
-
if (hookStatus === "skipped-malformed") {
|
|
2275
|
-
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
2276
|
-
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
2277
|
-
} else {
|
|
2278
|
-
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
// Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
|
|
2282
|
-
// runJourney() calls persistHome(ctx) right before forge() and stashes its
|
|
2283
|
-
// result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
|
|
2284
|
-
// wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
|
|
2285
|
-
// stable ~/.forge/ copy, so report that outcome instead of the raw
|
|
2286
|
-
// ephemeral-cache warning. Direct forge() callers that never set
|
|
2287
|
-
// ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
|
|
2288
|
-
// through to the original ephemeral-cache-only check unchanged.
|
|
2289
|
-
const persistResult = ctx.persistHomeResult;
|
|
2290
|
-
if (persistResult && !persistResult.skipped) {
|
|
2291
|
-
if (persistResult.migrated) {
|
|
2292
|
-
w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
|
|
2293
|
-
} else {
|
|
2294
|
-
w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
|
|
2295
|
-
}
|
|
2296
|
-
} else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
|
|
2297
|
-
// Skipped for a reason OTHER than "still ephemeral" — today that's always
|
|
2298
|
-
// the git-working-tree exemption (Acceptance Criteria #5). Informational,
|
|
2299
|
-
// not a warning: this is the expected, correct state for a git-clone install.
|
|
2300
|
-
w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
|
|
2301
|
-
} else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
|
|
2302
|
-
// Either persistHome() was never run (no ctx.persistHomeResult), or it
|
|
2303
|
-
// ran but failed and left ctx.forgeHome pointing at the original
|
|
2304
|
-
// ephemeral source (fail-open — see persistHome()'s error branch).
|
|
2305
|
-
w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
|
|
2306
|
-
w.write(fixCard([
|
|
2307
|
-
"This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
|
|
2308
|
-
"or OS temp cleanup. If that happens, the SessionStart hook silently stops",
|
|
2309
|
-
"injecting ForgeDock context — no error is shown.",
|
|
2310
|
-
"Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
|
|
2311
|
-
], ctx.mode, "warning") + "\n");
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
// Cross-environment install advisory (forge#1893): warn when this repo is
|
|
2315
|
-
// reachable from both WSL and native Windows and BOTH sides already have a
|
|
2316
|
-
// ForgeDock install. Each environment's install is fully independent (see
|
|
2317
|
-
// #1589 — installs are always global, never per-repo), so running from
|
|
2318
|
-
// both silently leaves forge.yaml/hook paths pointing at whichever side ran
|
|
2319
|
-
// most recently, clobbering the other's config with no error surfaced.
|
|
2320
|
-
// Advisory only — never fails the install.
|
|
2321
|
-
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
2322
|
-
const crossEnv = detectCrossEnvInstall(ctx, envInfo);
|
|
2323
|
-
if (crossEnv.conflict) {
|
|
2324
|
-
const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
|
|
2325
|
-
w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
|
|
2326
|
-
w.write(fixCard([
|
|
2327
|
-
`This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
|
|
2328
|
-
"install already exists. Each side's install is independent — running",
|
|
2329
|
-
"forgedock from both can leave forge.yaml and hook paths pointing at",
|
|
2330
|
-
"whichever environment ran last, silently overwriting the other's config.",
|
|
2331
|
-
"Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
|
|
2332
|
-
], ctx.mode, "warning") + "\n");
|
|
2403
|
+
manifestSaveFailed = !(await saveManifestWithMerge(manifestPath, manifestOps));
|
|
2333
2404
|
}
|
|
2334
2405
|
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
}
|
|
2341
|
-
// "skipped-malformed" and "absent" are intentionally silent here:
|
|
2342
|
-
// - skipped-malformed: installSessionStartHook() above reads the same
|
|
2343
|
-
// settingsPath and already prints the malformed-JSON fix-card, so a
|
|
2344
|
-
// second message would just repeat the same signal.
|
|
2345
|
-
// - absent: the expected steady state (nothing to remove) — this
|
|
2346
|
-
// function only reports state *changes*, not no-ops.
|
|
2347
|
-
if (subagentStopEnforceStatus === "removed") {
|
|
2348
|
-
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
2349
|
-
}
|
|
2406
|
+
renderForgeReceipt(ctx, {
|
|
2407
|
+
installed, updated, skipped, copied, backedUp, pruned, total: files.length,
|
|
2408
|
+
staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
|
|
2409
|
+
hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
|
|
2410
|
+
});
|
|
2350
2411
|
|
|
2351
2412
|
return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
2352
2413
|
}
|