skalpel 4.0.40 → 4.0.41
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/install.mjs +96 -28
- package/package.json +1 -1
- package/postinstall.mjs +33 -6
package/install.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
mkdirSync,
|
|
9
9
|
existsSync,
|
|
10
10
|
copyFileSync,
|
|
11
|
+
renameSync,
|
|
11
12
|
rmSync,
|
|
12
13
|
readdirSync,
|
|
13
14
|
} from "node:fs";
|
|
@@ -42,40 +43,95 @@ const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
|
|
|
42
43
|
const SESSION_END_FILE = join(HOOKS_DIR, "skalpel-hook-session-end.mjs");
|
|
43
44
|
const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
-
// wired
|
|
46
|
+
// The COMPLETE import closure that must live in ~/.skalpel/hooks: the CLI entrypoints Claude Code /
|
|
47
|
+
// Codex are wired to, plus every module those entrypoints (transitively) import. A hook wired to an
|
|
48
|
+
// absolute path whose imported module is absent crashes on EVERY invocation with
|
|
49
|
+
// `SyntaxError: ... does not provide an export named ...` / ERR_MODULE_NOT_FOUND — the exact
|
|
50
|
+
// 0.3.12–0.3.16 bug shape. Keep this list a superset of the closure of skalpel-hook.mjs /
|
|
51
|
+
// skalpel-hook-session.mjs / skalpel-hook-session-end.mjs / skalpel-statusline.mjs (see the trailing
|
|
52
|
+
// note on each line for who imports it). Order is irrelevant now — staging is atomic (all files land
|
|
53
|
+
// in a temp dir; the temp dir is only swapped into place after every copy succeeds).
|
|
54
|
+
const RUNTIME_FILES = [
|
|
55
|
+
"insights.mjs", // hooks + statusline + reveal + verify-shadow import ./insights.mjs
|
|
56
|
+
"reveal.mjs", // both prompt/session hooks import ./reveal.mjs
|
|
57
|
+
"transcript.mjs", // skalpel-hook + skalpel-statusline + verify-shadow import ./transcript.mjs (tailLines)
|
|
58
|
+
"skalpel-hook.mjs", // UserPromptSubmit entrypoint (wired)
|
|
59
|
+
"skalpel-hook-session.mjs", // SessionStart entrypoint (wired)
|
|
60
|
+
"incremental-ingest.mjs", // skalpel-hook-session-end.mjs imports ./incremental-ingest.mjs
|
|
61
|
+
"skalpel-hook-session-end.mjs", // SessionEnd entrypoint (wired)
|
|
62
|
+
"skalpel-statusline.mjs", // statusLine entrypoint (wired)
|
|
63
|
+
"anchor-shadow.mjs", // verify-shadow.mjs (staged worker) imports ./anchor-shadow.mjs — probe primitives
|
|
64
|
+
"verify-shadow.mjs", // claim-verification SHADOW worker (spawned by skalpel-hook.mjs)
|
|
65
|
+
"auth.mjs", // hooks import ./auth.mjs
|
|
66
|
+
"metrics.mjs", // hooks import ./metrics.mjs
|
|
67
|
+
"stats.mjs", // `node ~/.skalpel/hooks/stats.mjs` — local read-only accumulator
|
|
68
|
+
"autopsy.mjs", // `node ~/.skalpel/hooks/autopsy.mjs` — local read-only receipt
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Stage the hook runtime into ~/.skalpel/hooks so the wired absolute path is always valid, ATOMICALLY:
|
|
72
|
+
// copy the whole closure into a fresh temp dir and only swap it into place after EVERY copy succeeds.
|
|
73
|
+
// This is the fix for the partial-upgrade fail-open: the old in-place overwrite copied the new
|
|
74
|
+
// skalpel-hook.mjs BEFORE its auth.mjs/metrics.mjs, so a mid-copy I/O failure on an UPGRADE (hooks
|
|
75
|
+
// already wired to the stable path) left the NEW hook next to the OLD auth at the live path — every
|
|
76
|
+
// turn then crashed with a missing-export SyntaxError, and the all-or-nothing guard below only
|
|
77
|
+
// refused to WIRE, never rolled back the already-clobbered files. With atomic staging a failed copy
|
|
78
|
+
// leaves the EXISTING hooks dir 100% intact.
|
|
47
79
|
function stageHookRuntime() {
|
|
48
80
|
// Login and setup repair paths invoke the already-staged copy. Avoid copying a file onto itself.
|
|
49
81
|
if (PKG_DIR === HOOKS_DIR) return;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
const SKALPEL_DIR = dirname(HOOKS_DIR); // ~/.skalpel — staging/backup are siblings of hooks so every
|
|
83
|
+
const stagingDir = join(SKALPEL_DIR, `hooks.staging-${process.pid}`); // rename below is same-filesystem
|
|
84
|
+
const backupDir = join(SKALPEL_DIR, `hooks.bak-${process.pid}`); // (atomic) and never crosses devices.
|
|
85
|
+
|
|
86
|
+
// 1) Copy the WHOLE closure into a fresh temp dir. Scoped to THIS pid so a concurrent installer's
|
|
87
|
+
// staging-<otherpid> is never disturbed; drop any leftover from a crashed prior run of this pid.
|
|
88
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
89
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
90
|
+
try {
|
|
91
|
+
for (const name of RUNTIME_FILES) {
|
|
92
|
+
copyFileSync(join(PKG_DIR, name), join(stagingDir, name));
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// A copy failed part-way (incomplete tarball / disk error). The LIVE hooks dir was never touched —
|
|
96
|
+
// clean up the partial staging dir and rethrow so the caller refuses to wire.
|
|
97
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2) All copies succeeded — swap staging into place. rename can't overwrite a non-empty dir, so move
|
|
102
|
+
// the old dir aside first, move the new one in, then delete the old. Both renames are same-device
|
|
103
|
+
// (siblings under ~/.skalpel), so each is atomic. On ANY failure — including the old dir being
|
|
104
|
+
// locked (Windows) so it can't even be moved aside — clean up the staging dir, and if we already
|
|
105
|
+
// moved the old dir aside, roll it back so the wired path is never left without a hooks dir.
|
|
106
|
+
rmSync(backupDir, { recursive: true, force: true }); // stale bak from a crashed prior same-pid run
|
|
107
|
+
const hadLive = existsSync(HOOKS_DIR);
|
|
108
|
+
try {
|
|
109
|
+
if (hadLive) renameSync(HOOKS_DIR, backupDir);
|
|
110
|
+
renameSync(stagingDir, HOOKS_DIR);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (hadLive && !existsSync(HOOKS_DIR) && existsSync(backupDir)) {
|
|
113
|
+
try {
|
|
114
|
+
renameSync(backupDir, HOOKS_DIR); // restore the previous working install
|
|
115
|
+
} catch {
|
|
116
|
+
/* rollback best-effort; the original is still recoverable at backupDir */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
// Swap done. Dropping the old dir is best-effort: a failure here only leaves a stale hooks.bak-<pid>,
|
|
123
|
+
// never a broken wired path.
|
|
124
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
69
125
|
}
|
|
70
126
|
if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
|
|
71
127
|
try {
|
|
72
128
|
stageHookRuntime();
|
|
73
129
|
} catch (e) {
|
|
74
|
-
// Staging is all-or-nothing. A missing source file means the tarball is incomplete (a
|
|
75
|
-
// installer copies was left out of package.json "files")
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
130
|
+
// Staging is atomic and all-or-nothing. A missing source file means the tarball is incomplete (a
|
|
131
|
+
// file this installer copies was left out of package.json "files"); any copy error aborts staging
|
|
132
|
+
// with the EXISTING hooks dir left fully intact (never a half-updated dir at the wired path).
|
|
133
|
+
// Wiring on top of a failed stage would risk hooks that crash on every prompt — so refuse to wire
|
|
134
|
+
// instead; a loud failure beats a silent no-op.
|
|
79
135
|
console.error(`skalpel: could not stage hook runtime: ${e.message}`);
|
|
80
136
|
console.error("skalpel: hooks were NOT wired.");
|
|
81
137
|
process.exit(1);
|
|
@@ -222,7 +278,12 @@ function claude() {
|
|
|
222
278
|
} else if (statuslineOnly) {
|
|
223
279
|
return "preserved"; // a custom status line wins; do not rewrite unrelated Claude settings
|
|
224
280
|
}
|
|
225
|
-
|
|
281
|
+
// Only write when we actually added, refreshed, or removed one of OUR entries (mirrors codexToml's
|
|
282
|
+
// `if (hadToml) write` guard). Never CREATE a settings.json — nor re-serialize/churn an existing one
|
|
283
|
+
// — when there was nothing to do: an `uninstall` on a machine that never had our hooks leaves the
|
|
284
|
+
// file exactly as it was (or absent). Install always changes something here, so this only suppresses
|
|
285
|
+
// the no-op write.
|
|
286
|
+
if (installed || refreshed || removed) writeJson(CLAUDE, d);
|
|
226
287
|
if (uninstall) return removed ? "removed" : "absent";
|
|
227
288
|
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
228
289
|
}
|
|
@@ -378,8 +439,15 @@ function codexJson() {
|
|
|
378
439
|
installed += 1;
|
|
379
440
|
}
|
|
380
441
|
}
|
|
442
|
+
if (uninstall) {
|
|
443
|
+
// Never CREATE ~/.codex/hooks.json during an uninstall — only rewrite it when we actually stripped
|
|
444
|
+
// one of our blocks from an existing file (mirrors codexToml's hadToml guard). A machine whose
|
|
445
|
+
// ~/.codex has no hooks.json (or none of ours) has nothing to clean up, so writing here would drop
|
|
446
|
+
// a stray file behind an operation that's supposed to leave the machine clean.
|
|
447
|
+
if (removed) writeJson(CODEX, d);
|
|
448
|
+
return removed ? "removed" : "absent";
|
|
449
|
+
}
|
|
381
450
|
writeJson(CODEX, d);
|
|
382
|
-
if (uninstall) return removed ? "removed" : "absent";
|
|
383
451
|
return installed ? "installed" : refreshed ? "refreshed" : "absent";
|
|
384
452
|
}
|
|
385
453
|
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -48,12 +48,37 @@ function retireLegacyDaemon() {
|
|
|
48
48
|
|
|
49
49
|
function stageStatusline() {
|
|
50
50
|
try {
|
|
51
|
-
// --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet.
|
|
52
|
-
|
|
51
|
+
// --statusline-only: stage the read-only indicator, do NOT wire prompt/session hooks yet. NOTE:
|
|
52
|
+
// this still runs the FULL hook-runtime staging inside install.mjs — so if staging genuinely fails
|
|
53
|
+
// (an incomplete package, a mid-copy I/O error) install.mjs exits non-zero. We MUST read that
|
|
54
|
+
// status: an unattended `npm install/update -g` upgrade that hits a staging failure would otherwise
|
|
55
|
+
// leave a broken/stale runtime with postinstall exiting 0 — a failure masquerading as success.
|
|
56
|
+
const r = spawnSync(process.execPath, [join(DIR, "install.mjs"), "--statusline-only"], {
|
|
53
57
|
stdio: "inherit",
|
|
54
58
|
});
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
if (r.error || r.status !== 0) {
|
|
60
|
+
// Surface it LOUDLY (stderr), but keep the fail-open posture: package.json wraps postinstall in
|
|
61
|
+
// `|| true`, so npm install itself is never bricked — the user still gets the package and can
|
|
62
|
+
// repair with `skalpel setup`. A first install where hooks aren't wired yet does NOT reach here:
|
|
63
|
+
// install.mjs only exits non-zero on a real staging failure, not on a clean statusline-only stage.
|
|
64
|
+
const detail = r.error
|
|
65
|
+
? r.error.message
|
|
66
|
+
: r.signal
|
|
67
|
+
? `killed by ${r.signal}`
|
|
68
|
+
: `exit code ${r.status}`;
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
`\n skalpel: WARNING — hook staging failed (${detail}). Hooks/status line may be missing or ` +
|
|
71
|
+
`stale. Run \x1b[1mskalpel setup\x1b[22m to repair.\n\n`,
|
|
72
|
+
);
|
|
73
|
+
process.exitCode = 1; // record the failure; package.json's `|| true` keeps npm install green
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// spawn itself failed (couldn't launch node at all) — still surface, still fail-open.
|
|
79
|
+
process.stderr.write(`\n skalpel: WARNING — could not run the installer (${e.message}).\n\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return false;
|
|
57
82
|
}
|
|
58
83
|
}
|
|
59
84
|
|
|
@@ -75,11 +100,13 @@ function fixSudoOwnership() {
|
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
retireLegacyDaemon();
|
|
78
|
-
stageStatusline();
|
|
103
|
+
const stagingOk = stageStatusline();
|
|
79
104
|
fixSudoOwnership();
|
|
80
105
|
|
|
81
106
|
// A quiet nudge — the real onboarding (Google sign-in → build graph → insights) runs on `skalpel`.
|
|
82
|
-
|
|
107
|
+
// Suppressed when staging failed: the warning above is the honest signal, and a cheerful
|
|
108
|
+
// "installed" line under it would read as success.
|
|
109
|
+
if (stagingOk && process.stdout.isTTY && !process.env.CI) {
|
|
83
110
|
process.stdout.write(
|
|
84
111
|
"\n skalpel installed. Run \x1b[1mskalpel\x1b[22m to sign in and build your graph.\n\n",
|
|
85
112
|
);
|