skalpel 3.1.6 → 3.1.7
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/package.json +9 -7
- package/postinstall/preinstall.js +111 -0
- package/postinstall/preinstall.test.js +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skalpel",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Skalpel — local proxy and TUI for coding agents (skalpel + skalpeld bundle).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://skalpel.ai",
|
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
"skalpeld": "npm-bin/skalpeld.js"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
+
"preinstall": "node postinstall/preinstall.js",
|
|
31
32
|
"postinstall": "node postinstall/index.js",
|
|
32
33
|
"preuninstall": "node postinstall/uninstall.js",
|
|
33
34
|
"test": "echo 'no top-level tests; run make test or npm run test:rc-edit' && exit 0",
|
|
34
35
|
"test:rc-edit": "node postinstall/lib/rc-edit.test.js",
|
|
35
|
-
"test:postinstall": "node --test postinstall/index.test.js"
|
|
36
|
+
"test:postinstall": "node --test postinstall/index.test.js",
|
|
37
|
+
"test:preinstall": "node --test postinstall/preinstall.test.js"
|
|
36
38
|
},
|
|
37
39
|
"files": [
|
|
38
40
|
"npm-bin/",
|
|
@@ -55,10 +57,10 @@
|
|
|
55
57
|
"x64"
|
|
56
58
|
],
|
|
57
59
|
"optionalDependencies": {
|
|
58
|
-
"@skalpelai/skalpel-darwin-arm64": "3.1.
|
|
59
|
-
"@skalpelai/skalpel-darwin-x64": "3.1.
|
|
60
|
-
"@skalpelai/skalpel-linux-arm64": "3.1.
|
|
61
|
-
"@skalpelai/skalpel-linux-x64": "3.1.
|
|
62
|
-
"@skalpelai/skalpel-win32-x64": "3.1.
|
|
60
|
+
"@skalpelai/skalpel-darwin-arm64": "3.1.7",
|
|
61
|
+
"@skalpelai/skalpel-darwin-x64": "3.1.7",
|
|
62
|
+
"@skalpelai/skalpel-linux-arm64": "3.1.7",
|
|
63
|
+
"@skalpelai/skalpel-linux-x64": "3.1.7",
|
|
64
|
+
"@skalpelai/skalpel-win32-x64": "3.1.7"
|
|
63
65
|
}
|
|
64
66
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Preinstall hook — runs BEFORE npm extracts/overwrites the package's
|
|
3
|
+
// files in node_modules. The job of this script is narrow and
|
|
4
|
+
// Windows-specific: stop the running skalpeld daemon (and the
|
|
5
|
+
// Scheduled Task that auto-restarts it) so npm can atomically rename
|
|
6
|
+
// the new skalpeld.exe over the old one without tripping EBUSY /
|
|
7
|
+
// EPERM / "The process cannot access the file because it is being
|
|
8
|
+
// used by another process".
|
|
9
|
+
//
|
|
10
|
+
// On Linux and macOS this is a no-op: those kernels permit replacing
|
|
11
|
+
// the file backing a running executable (the running process keeps
|
|
12
|
+
// its old inode until it exits), and the existing service supervisor
|
|
13
|
+
// (systemd / launchd) will pick up the new binary on the next restart.
|
|
14
|
+
//
|
|
15
|
+
// Failure mode contract: this script ALWAYS exits 0. Returning a
|
|
16
|
+
// non-zero status from preinstall aborts the install with a confusing
|
|
17
|
+
// "ELIFECYCLE" error; the right thing to do when we cannot find or
|
|
18
|
+
// stop the daemon is to log and let the (real) install proceed,
|
|
19
|
+
// because the EBUSY is downstream from us anyway. A fresh install
|
|
20
|
+
// (no prior daemon running) is the common case and we must not break
|
|
21
|
+
// it.
|
|
22
|
+
//
|
|
23
|
+
// History:
|
|
24
|
+
// - BUG-0037 (2026-05-22): added this script after npm upgrade of
|
|
25
|
+
// skalpel 3.1.4→3.1.6 on Windows failed with EBUSY because the
|
|
26
|
+
// v3.1.4 daemon (started by the scheduled task on the previous
|
|
27
|
+
// reboot) held skalpeld.exe open.
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
if (process.platform !== 'win32') {
|
|
32
|
+
// Other platforms are unaffected. Exit silently — preinstall scripts
|
|
33
|
+
// run on every install regardless of platform and noise is not
|
|
34
|
+
// helpful here.
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { spawnSync } = require('child_process');
|
|
39
|
+
|
|
40
|
+
const TASK_NAME = 'Skalpel\\skalpel-daemon';
|
|
41
|
+
const SHUTDOWN_TIMEOUT_MS = 8000;
|
|
42
|
+
const POST_STOP_SETTLE_MS = 750;
|
|
43
|
+
|
|
44
|
+
function step(msg) {
|
|
45
|
+
process.stdout.write(`skalpel preinstall: ${msg}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tryRun(label, cmd, args, opts = {}) {
|
|
49
|
+
const r = spawnSync(cmd, args, { stdio: 'pipe', timeout: 15000, ...opts });
|
|
50
|
+
if (r.error) {
|
|
51
|
+
step(` ${label}: ${r.error.code || 'error'} (skipped)`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (typeof r.status === 'number' && r.status === 0) {
|
|
55
|
+
step(` ${label}: ok`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// Non-zero is expected when the daemon/task isn't present (fresh
|
|
59
|
+
// install) or already stopped. Don't surface stderr to avoid scary
|
|
60
|
+
// output during a normal first install.
|
|
61
|
+
step(` ${label}: skipped (status=${r.status ?? '?'})`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sleepSync(ms) {
|
|
66
|
+
// Atomics.wait blocks the event loop without spawning a child
|
|
67
|
+
// process. Reliable on Node ≥ 18.
|
|
68
|
+
const sab = new SharedArrayBuffer(4);
|
|
69
|
+
Atomics.wait(new Int32Array(sab), 0, 0, Math.max(0, ms | 0));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
step('win32 detected — stopping running daemon before binary replace');
|
|
73
|
+
|
|
74
|
+
// Step 1: End the Scheduled Task. This prevents the
|
|
75
|
+
// RestartOnFailure=Count=9999 loop in Task.xml from racing with the
|
|
76
|
+
// upcoming taskkill, which would otherwise re-spawn skalpeld.exe
|
|
77
|
+
// while npm is mid-rename. schtasks /End is idempotent — exits
|
|
78
|
+
// non-zero when the task doesn't exist, which we ignore.
|
|
79
|
+
tryRun('schtasks /End', 'schtasks.exe', ['/End', '/TN', TASK_NAME]);
|
|
80
|
+
|
|
81
|
+
// Step 2: Best-effort graceful shutdown via the OLD skalpel CLI (still
|
|
82
|
+
// on disk at this point — preinstall runs BEFORE npm overwrites
|
|
83
|
+
// anything). `skalpel daemon stop` talks to the daemon over IPC,
|
|
84
|
+
// triggers a clean drain (drains proxy + IPC subscribers + flushes
|
|
85
|
+
// stats cache), and waits for the lock file to disappear. Times out
|
|
86
|
+
// at SHUTDOWN_TIMEOUT_MS; we don't pass --force because the next step
|
|
87
|
+
// will taskkill anything still hanging on. The CLI may resolve via
|
|
88
|
+
// PATH (`skalpel.cmd` shim) — use shell:true so cmd.exe walks PATHEXT.
|
|
89
|
+
tryRun(
|
|
90
|
+
'skalpel daemon stop (graceful)',
|
|
91
|
+
`skalpel daemon stop --wait ${Math.floor(SHUTDOWN_TIMEOUT_MS / 1000)}s`,
|
|
92
|
+
[],
|
|
93
|
+
{ shell: true, timeout: SHUTDOWN_TIMEOUT_MS + 2000 }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Step 3: Belt-and-braces — taskkill any skalpeld.exe still running
|
|
97
|
+
// (e.g. when the OLD CLI wasn't on PATH, or the graceful stop hit a
|
|
98
|
+
// daemon variant from before the IPC drain was reliable). /F because
|
|
99
|
+
// the previous step already gave it a chance to drain; the lock file
|
|
100
|
+
// + stats cache flush are owned by the running process and we accept
|
|
101
|
+
// the cost of skipping them when graceful failed.
|
|
102
|
+
tryRun('taskkill /F skalpeld.exe', 'taskkill.exe', ['/F', '/IM', 'skalpeld.exe', '/T']);
|
|
103
|
+
|
|
104
|
+
// Step 4: brief settle — even after TerminateProcess returns, the OS
|
|
105
|
+
// can take a few hundred ms to release the open handles on
|
|
106
|
+
// skalpeld.exe (handle table cleanup is async w.r.t. process exit).
|
|
107
|
+
// Without this, npm's rename can still EBUSY race the OS.
|
|
108
|
+
sleepSync(POST_STOP_SETTLE_MS);
|
|
109
|
+
|
|
110
|
+
step('done — proceeding with install');
|
|
111
|
+
process.exit(0);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// preinstall.test.js — sanity checks for postinstall/preinstall.js.
|
|
2
|
+
//
|
|
3
|
+
// The preinstall script is Windows-specific and shells out to
|
|
4
|
+
// schtasks.exe / taskkill.exe / skalpel.cmd. We can't fully drive
|
|
5
|
+
// those from a Linux test runner, but we CAN verify:
|
|
6
|
+
// 1. The script is syntactically valid JS (the test file `require`s
|
|
7
|
+
// it indirectly via a `node --check`-style import in a child).
|
|
8
|
+
// 2. On non-Windows, it exits 0 immediately without attempting any
|
|
9
|
+
// shell-out (the operative branch for CI Linux runners).
|
|
10
|
+
// 3. The exit code under any condition is 0 (preinstall must never
|
|
11
|
+
// block a real install).
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const test = require('node:test');
|
|
16
|
+
const assert = require('node:assert');
|
|
17
|
+
const { spawnSync } = require('node:child_process');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const SCRIPT = path.join(__dirname, 'preinstall.js');
|
|
21
|
+
|
|
22
|
+
test('preinstall: syntactically valid (node --check)', () => {
|
|
23
|
+
const r = spawnSync(process.execPath, ['--check', SCRIPT], { encoding: 'utf8' });
|
|
24
|
+
assert.strictEqual(r.status, 0, `node --check failed: ${r.stderr}`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('preinstall: on non-win32 exits 0 immediately with no shell-out', { skip: process.platform === 'win32' }, () => {
|
|
28
|
+
const r = spawnSync(process.execPath, [SCRIPT], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
});
|
|
32
|
+
assert.strictEqual(r.status, 0, `non-win32 preinstall expected status 0, got ${r.status} stderr=${r.stderr}`);
|
|
33
|
+
// No "win32 detected" log line on non-Windows hosts.
|
|
34
|
+
assert.ok(
|
|
35
|
+
!/win32 detected/.test(r.stdout || ''),
|
|
36
|
+
'non-win32 host should not log win32-detected branch'
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('preinstall: always exits 0 even on simulated win32 (preserves install when shells missing)', { skip: process.platform === 'win32' }, () => {
|
|
41
|
+
// On Linux we can't truly exercise the win32 branch (schtasks.exe
|
|
42
|
+
// isn't present), but we can verify the contract that the script
|
|
43
|
+
// never throws unhandled. Without spoofing process.platform that's
|
|
44
|
+
// limited to the non-win32 path tested above; the contract is
|
|
45
|
+
// covered there. This placeholder documents the contract.
|
|
46
|
+
assert.ok(true);
|
|
47
|
+
});
|