skalpel 3.1.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.1.5",
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.5",
59
- "@skalpelai/skalpel-darwin-x64": "3.1.5",
60
- "@skalpelai/skalpel-linux-arm64": "3.1.5",
61
- "@skalpelai/skalpel-linux-x64": "3.1.5",
62
- "@skalpelai/skalpel-win32-x64": "3.1.5"
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
  }
@@ -45,8 +45,15 @@ function run({ dryRun, port }) {
45
45
  for (const rc of rcs) {
46
46
  const exists = fs.existsSync(rc.path);
47
47
  if (!exists) {
48
- log.info(`env-inject: ${rc.shell} rc absent (${rc.path}) — skip`);
49
- continue;
48
+ // BUG-0035: on Windows most users have no PowerShell $PROFILE yet,
49
+ // but the `claude` wrapper still must be installed — applyBlock
50
+ // creates the file (and parent dir). On POSIX, skip absent rc files
51
+ // (the present.length===0 fallback below bootstraps ~/.profile).
52
+ if (process.platform !== 'win32') {
53
+ log.info(`env-inject: ${rc.shell} rc absent (${rc.path}) — skip`);
54
+ continue;
55
+ }
56
+ log.info(`env-inject: ${rc.shell} rc absent (${rc.path}) — creating (Windows)`);
50
57
  }
51
58
  present.push(rc);
52
59
  if (dryRun) {
@@ -47,11 +47,11 @@ end`,
47
47
  powershell: `
48
48
  # Pre-launch status hint + fail-open for Claude Code.
49
49
  # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
50
- $_skalpelOrigClaude = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
50
+ $_skalpelOrigClaude = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
51
51
  $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
52
52
  if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatusBin) {
53
53
  function global:claude {
54
- & $script:_skalpelStatusBin.Source status 1>&2
54
+ & $script:_skalpelStatusBin.Source status | Out-Host
55
55
  if ($LASTEXITCODE -eq 0) {
56
56
  & $script:_skalpelStatusBin.Source claude-exec @args
57
57
  } else {
@@ -134,11 +134,11 @@ end`,
134
134
  powershell: `
135
135
  # Codex CLI/App integration. Config overrides are native Codex -c knobs.
136
136
  # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
137
- $_skalpelOrigCodex = Get-Command codex.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
137
+ $_skalpelOrigCodex = Get-Command codex -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
138
138
  $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
139
139
  if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigCodex -and $_skalpelStatusBin) {
140
140
  function global:codex {
141
- & $script:_skalpelStatusBin.Source status 1>&2
141
+ & $script:_skalpelStatusBin.Source status | Out-Host
142
142
  if ($LASTEXITCODE -eq 0) {
143
143
  $_skalpelCodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' }
144
144
  $_skalpelCodexAuthMode = ''
@@ -198,6 +198,26 @@ function run() {
198
198
  assert.ok(!block.includes('network.proxy_url'), 'Codex wrapper should not generic-proxy metadata routes');
199
199
  });
200
200
 
201
+ test('TestRcEdit_PowerShell_Wrapper_Valid_Syntax', () => {
202
+ // BUG-0036 regression: the PowerShell wrappers must not use `1>&2`
203
+ // (a PowerShell parse error → the whole profile block fails to load
204
+ // → the claude/codex functions are never defined → bare `claude`
205
+ // bypasses skalpel on Windows). And the original-binary lookup must
206
+ // target `claude`/`codex` (npm installs claude.cmd/.ps1 on Windows),
207
+ // not `claude.exe`/`codex.exe` (which don't exist → null → wrapper
208
+ // never engages).
209
+ const env = rc.envBlockValues(7878);
210
+ const block = rc.buildBlock('powershell', env);
211
+ assert.ok(!block.includes('1>&2'), 'PowerShell wrapper uses unsupported 1>&2 redirect');
212
+ assert.ok(!block.includes('claude.exe'), 'PowerShell wrapper looks up claude.exe (absent on npm Windows)');
213
+ assert.ok(!block.includes('codex.exe'), 'PowerShell wrapper looks up codex.exe (absent on npm Windows)');
214
+ assert.ok(
215
+ block.includes('Get-Command claude -CommandType Application'),
216
+ 'PowerShell wrapper should resolve claude via Get-Command claude'
217
+ );
218
+ assert.ok(block.includes('| Out-Host'), 'PowerShell wrapper should route status output via | Out-Host');
219
+ });
220
+
201
221
  process.stdout.write(`\n pass=${pass} fail=${fail}\n`);
202
222
  return fail === 0 ? 0 : 1;
203
223
  }
@@ -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
+ });