skalpel 3.2.1 → 3.2.3

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.md CHANGED
@@ -37,23 +37,23 @@ All three paths produce identical binaries — same SHAs, same cosign signatures
37
37
 
38
38
  ## First-run flow
39
39
 
40
- Post-W4 (OAuth port): the first-run flow is a single browser-loopback Cognito sign-in. The historical `sk-skalpel-*` API-key paste path is gone — the daemon no longer persists or sends api_keys on any code path. The flow is:
40
+ Post-W4 (OAuth port): first run is a guided TUI walkthrough plus browser-loopback Cognito sign-in. The historical `sk-skalpel-*` API-key paste path is gone — the daemon no longer persists or sends api_keys on any code path. The flow is:
41
41
 
42
- 1. The user runs `npx skalpel login` (or `skalpel login` after a global install). The npm package is materialized; both binaries are present.
43
- 2. `skalpel login` opens a browser to the Skalpel hosted-UI sign-in page (Cognito), waits on a loopback HTTP callback, and validates the signed envelope returned by the callback.
44
- 3. On a verified envelope, the daemon writes `auth.json` to the per-OS configuration directory (per `SPEC.md` §7). The file is owned by the user with `0600` permissions and carries the Cognito JWT bundle (access_token, refresh_token, expires_at).
45
- 4. The user runs `skalpel` (or `npx skalpel`). The TUI detects a usable `auth.json`, invokes `skalpeld` to start the daemon, and shows the `Starting skalpeld…` splash for up to three seconds (per spec §8.5).
46
- 5. Once the daemon is reachable, the TUI lands the user on the Engines tab. The matrix renders for whatever agents the daemon has detected so far. If no agents have been opened since installation, the empty-state message from spec §3.2 is shown.
42
+ 1. The user installs (`npm install -g skalpel`) and runs `skalpel`.
43
+ 2. If `auth.json` is missing, `skalpel` renders a branded walkthrough in-terminal: daemon status, sign-in requirement, CA trust behavior, wrapper scope (new sessions only), and quick toggles (`skalpel off` / `skalpel on`).
44
+ 3. On Enter, `skalpel` opens a browser to the Skalpel hosted-UI sign-in page (Cognito), waits on a loopback HTTP callback, and validates the signed envelope returned by the callback.
45
+ 4. On a verified envelope, the daemon writes `auth.json` to the per-OS configuration directory (per `SPEC.md` §7). The file is owned by the user with `0600` permissions and carries the Cognito JWT bundle (access_token, refresh_token, expires_at).
46
+ 5. The TUI auto-launches into the Dashboard. Once the daemon is reachable, the app surfaces live status and tabs (Dashboard / Engines / Account) with the first-run intro banner.
47
47
 
48
48
  This flow corresponds to Journey 1 — First launch, fresh authentication — in `SPEC.md`. That narrative describes what the user feels at each step; this document describes what the install machinery actually does.
49
49
 
50
- If the user runs `skalpel` without first running `skalpel login`, the TUI exits per spec §8.4 with a stderr pointer at `skalpel login`. To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
50
+ If the user runs `skalpel` without first running `skalpel login`, the walkthrough prompts them to sign in inline (Enter), skip for now (`s`), turn interception off (`o`), or quit (`q`). To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
51
51
 
52
52
  Historical note: prior versions of skalpel supported pasting a `sk-skalpel-*` API key copied from the web dashboard as an alternative to `skalpel login`. The W4 OAuth-port grep-delete removed both the api_key-paste wizard and the daemon-side api_key sending path. Users on stale auth.json files (api_key-only, no Cognito bundle) need to run `skalpel login` once to refresh.
53
53
 
54
54
  ## Daemon lifecycle on subsequent launches
55
55
 
56
- On every launch after the first, the TUI checks whether `skalpeld` is reachable. If it is, the TUI proceeds to the Engines tab without further interaction. If it is not, the TUI attempts to start it (per spec §8.5) and shows the `Starting skalpeld…` splash for up to three seconds. If the daemon does not come up in that window, the TUI proceeds with the daemon-unreachable banner and the user is offered a `[r]` retry affordance.
56
+ On every launch after the first, the TUI checks whether `skalpeld` is reachable. If it is, the TUI proceeds to Dashboard without further interaction. If it is not, the TUI attempts to start it (per spec §8.5) and shows the `Starting skalpeld…` splash for up to three seconds. If the daemon does not come up in that window, the TUI proceeds with the daemon-unreachable banner and the user is offered a `[r]` retry affordance.
57
57
 
58
58
  The TUI is not a process supervisor. Per `SPEC.md` §01: the TUI tries to start the daemon at launch and surfaces the daemon's state via the banner, but there is no "stop daemon," "restart daemon," or "view daemon logs" control in the TUI; those are shell-level concerns. The install machinery makes both binaries available; the runtime relationship between them is the TUI's start-on-launch attempt and nothing more.
59
59
 
@@ -145,15 +145,24 @@ During `npm install -g skalpel` on macOS you will see a **one-time Touch ID / pa
145
145
  - Trusts only the daemon-minted local CA labelled `Skalpel Local Intercept CA`.
146
146
  - Used exclusively for `chatgpt.com` / `api.openai.com` traffic the daemon intercepts; every other host is blind-tunnelled untouched.
147
147
 
148
- **If you missed the prompt** or declined it, run `skalpel login` or invoke `codex` once — the postinstall step writes a deferred-install sentinel and the next entry point retries the keychain install.
148
+ **If you missed the prompt** or declined it, run `skalpel login` or invoke `codex` once — the postinstall step writes a deferred-install sentinel and the next entry point retries the trust-store install.
149
149
 
150
- **To revoke trust:**
150
+ **macOS revoke trust:**
151
151
 
152
152
  1. Open **Keychain Access**.
153
153
  2. Select the `login` keychain.
154
154
  3. Search for `Skalpel Local Intercept CA`.
155
155
  4. Right-click → Delete.
156
156
 
157
+ **Linux — revoke trust:**
158
+
159
+ ```bash
160
+ sudo rm /usr/local/share/ca-certificates/skalpel-local-intercept-ca.crt
161
+ sudo update-ca-certificates --fresh
162
+ ```
163
+
164
+ **Linux install requirements:** postinstall (and the deferred retry on first `skalpel login` / `codex` invocation) copies the daemon CA to `/usr/local/share/ca-certificates/skalpel-local-intercept-ca.crt` and runs `update-ca-certificates`. This needs **root** or **passwordless `sudo`**. If `sudo` is unavailable, postinstall prints the manual commands and does not fail the install.
165
+
157
166
  After deletion, Codex traffic falls back to bare TLS (daemon won't see it). Re-install the CA via `skalpel login` if you change your mind.
158
167
 
159
- **Linux / Windows status:** the trust-store install path is **not yet shipped** on Linux or Windows. Codex MITM works on macOS only in this release. On other platforms the postinstall step skips the keychain step with a printed notice; the user can configure trust manually if they need Codex visibility.
168
+ **Windows status:** the trust-store install path is **not yet shipped** on Windows. Codex MITM on Windows still requires manual cert trust until a `certutil` path lands.
package/README.md CHANGED
@@ -16,7 +16,21 @@ npx skalpel login
16
16
  npm install -g skalpel
17
17
  ```
18
18
 
19
- Both `skalpel` and `skalpeld` are placed on your `PATH`. For details on what gets installed where, per-OS service registration, updates, and uninstall, see [INSTALL.md](./INSTALL.md).
19
+ Both `skalpel` and `skalpeld` are placed on your `PATH`. Then run:
20
+
21
+ ```
22
+ skalpel
23
+ ```
24
+
25
+ to open the guided first-run walkthrough (brand banner, step-by-step setup, sign-in handoff, and toggle tips).
26
+
27
+ Note: npm may hide lifecycle-script output; if you want to see install wizard logs during `npm install`, use:
28
+
29
+ ```
30
+ npm install -g skalpel --foreground-scripts
31
+ ```
32
+
33
+ For details on what gets installed where, per-OS service registration, updates, and uninstall, see [INSTALL.md](./INSTALL.md).
20
34
 
21
35
  ## Subcommands
22
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
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",
@@ -57,10 +57,10 @@
57
57
  "x64"
58
58
  ],
59
59
  "optionalDependencies": {
60
- "@skalpelai/skalpel-darwin-arm64": "3.2.1",
61
- "@skalpelai/skalpel-darwin-x64": "3.2.1",
62
- "@skalpelai/skalpel-linux-arm64": "3.2.1",
63
- "@skalpelai/skalpel-linux-x64": "3.2.1",
64
- "@skalpelai/skalpel-win32-x64": "3.2.1"
60
+ "@skalpelai/skalpel-darwin-arm64": "3.2.3",
61
+ "@skalpelai/skalpel-darwin-x64": "3.2.3",
62
+ "@skalpelai/skalpel-linux-arm64": "3.2.3",
63
+ "@skalpelai/skalpel-linux-x64": "3.2.3",
64
+ "@skalpelai/skalpel-win32-x64": "3.2.3"
65
65
  }
66
66
  }
@@ -112,14 +112,14 @@ async function runCAInstallStep(opts) {
112
112
  // ignored — fall through to deferred-no-ca branch
113
113
  }
114
114
  if (!caPath) {
115
- log.warn('ca-install: could not resolve `skalpel ca-path`; deferring keychain trust to first login');
115
+ log.warn('ca-install: could not resolve `skalpel ca-path`; deferring trust-store install to first login');
116
116
  return { ok: false, reason: 'no-binary' };
117
117
  }
118
118
  if (opts.dryRun) {
119
- log.info(`ca-install: dry-run; would install ${caPath} into login keychain`);
119
+ log.info(`ca-install: dry-run; would install ${caPath} into OS trust store`);
120
120
  return { ok: true, skipped: true, reason: 'dry-run' };
121
121
  }
122
- return caInstall.installMacOSCA(caPath);
122
+ return caInstall.installDaemonCA(caPath);
123
123
  }
124
124
 
125
125
  async function main(argv) {
@@ -184,12 +184,10 @@ async function main(argv) {
184
184
  allWarnings.push(...sr.warnings);
185
185
  }
186
186
 
187
- log.step(4, total, 'ca-install', `keychain trust on ${process.platform}`);
188
- // ca-install is NON-CRITICAL: a declined Touch ID prompt must NOT
187
+ log.step(4, total, 'ca-install', `trust store on ${process.platform}`);
188
+ // ca-install is NON-CRITICAL: declined prompts or missing sudo must NOT
189
189
  // fail postinstall. The deferred-install sentinel ensures first
190
- // `skalpel login` / `codex` invocation retries the trust. On
191
- // non-darwin we log + skip; Linux/Windows trust-store install is
192
- // deferred to a later bundle.
190
+ // `skalpel login` / `codex` invocation retries the trust install.
193
191
  try {
194
192
  const caRes = await runCAInstallStep(opts);
195
193
  if (caRes && !caRes.ok && caRes.reason) {
@@ -1,24 +1,16 @@
1
1
  'use strict';
2
2
 
3
- // macOS keychain CA install for the daemon's MITM CA.
3
+ // Platform trust-store install for the daemon's MITM CA.
4
4
  //
5
- // Codex CLI is Rust and consults the OS keychain (Security Framework on
6
- // macOS) for trust. To make `skalpel codex-exec` work end-to-end on a
7
- // fresh `npm install -g skalpel`, the postinstall flow trusts the
8
- // daemon's local CA in the user's login keychain. This module owns that
9
- // step.
5
+ // macOS: login keychain via `security add-trusted-cert`.
6
+ // Linux: /usr/local/share/ca-certificates + `update-ca-certificates`.
10
7
  //
11
- // Contract:
12
- // installMacOSCA(caPath, opts) returns
13
- // { ok: true } installed (or already trusted)
14
- // { ok: true, skipped: true, reason: '...' } non-darwin or idempotent skip
15
- // { ok: false, reason: '...' } — user declined / timeout /
16
- // deferred (CA not yet on disk)
8
+ // Contract (installDaemonCA / installMacOSCA):
9
+ // { ok: true } — installed (or already trusted)
10
+ // { ok: true, skipped: true, reason: '...' } idempotent skip / unsupported OS
11
+ // { ok: false, reason: '...' } deferred / declined / failed
17
12
  //
18
- // Failure is NEVER fatal to postinstall. The user falls back to a
19
- // printed-instruction path and the next `skalpel codex-exec` /
20
- // `skalpel login` retries via the Go-side `internal/cainstall`
21
- // RetrySentinel helper.
13
+ // Failure is NEVER fatal to postinstall.
22
14
 
23
15
  const fs = require('fs');
24
16
  const os = require('os');
@@ -29,80 +21,167 @@ const KEYCHAIN_LABEL = 'Skalpel Local Intercept CA';
29
21
  const DEFAULT_TIMEOUT_MS = 60_000;
30
22
  const SENTINEL_BASENAME = '.ca-install-pending';
31
23
 
32
- // loginKeychainPath returns the path to the user's login keychain on
33
- // macOS. macOS Catalina+ uses .keychain-db.
24
+ const LINUX_CA_DIR = '/usr/local/share/ca-certificates';
25
+ const LINUX_CA_BASENAME = 'skalpel-local-intercept-ca.crt';
26
+
34
27
  function loginKeychainPath(homedir) {
35
28
  return path.join(homedir, 'Library', 'Keychains', 'login.keychain-db');
36
29
  }
37
30
 
31
+ function linuxTargetPath() {
32
+ return path.join(LINUX_CA_DIR, LINUX_CA_BASENAME);
33
+ }
34
+
38
35
  function logMsg(stream, msg) {
39
36
  if (stream && typeof stream.write === 'function') {
40
37
  stream.write(`${msg}\n`);
41
38
  }
42
39
  }
43
40
 
44
- // installMacOSCA installs the daemon's MITM CA into the user's login
45
- // keychain, prompting the user for Touch ID / password. Idempotent —
46
- // re-running after a successful install is a no-op.
47
- //
48
- // opts:
49
- // spawn: optional injected child_process.spawn for tests
50
- // stderr: optional Writable for status messages (defaults to process.stderr)
51
- // homedir: optional homedir for tests (defaults to os.homedir())
52
- // timeoutMs: optional timeout override (defaults to 60s)
53
- // sentinelDir: optional dir where the deferred-install sentinel is written
54
- // when caPath does not exist (defaults to dirname(caPath))
55
- async function installMacOSCA(caPath, opts) {
41
+ function caFilesMatch(leftPath, rightPath) {
42
+ try {
43
+ const a = fs.readFileSync(leftPath);
44
+ const b = fs.readFileSync(rightPath);
45
+ return a.length === b.length && a.equals(b);
46
+ } catch (_) {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function writeDeferredSentinel(caPath, opts, stderr) {
52
+ const sentinelDir = opts.sentinelDir || path.dirname(caPath);
53
+ const sentinelPath = path.join(sentinelDir, SENTINEL_BASENAME);
54
+ try {
55
+ fs.mkdirSync(sentinelDir, { recursive: true });
56
+ fs.writeFileSync(sentinelPath, '');
57
+ } catch (err) {
58
+ logMsg(stderr, `Skalpel: could not write ca-install sentinel at ${sentinelPath}: ${err.message}`);
59
+ }
60
+ logMsg(
61
+ stderr,
62
+ 'Skalpel: daemon CA not yet generated — will install on first `skalpel login` or `codex` invocation.'
63
+ );
64
+ return { ok: false, reason: 'deferred-no-ca', sentinelPath };
65
+ }
66
+
67
+ function isRootUser() {
68
+ return typeof process.getuid === 'function' && process.getuid() === 0;
69
+ }
70
+
71
+ function runPrivileged(spawnSync, bin, args) {
72
+ const fn = spawnSync || childProcess.spawnSync;
73
+ if (isRootUser()) {
74
+ return fn(bin, args, { encoding: 'utf8' });
75
+ }
76
+ return fn('sudo', ['-n', bin, ...args], { encoding: 'utf8' });
77
+ }
78
+
79
+ function sudoAvailable(spawnSync) {
80
+ const fn = spawnSync || childProcess.spawnSync;
81
+ const probe = fn('sudo', ['-n', 'true'], { encoding: 'utf8' });
82
+ return probe.status === 0;
83
+ }
84
+
85
+ // installDaemonCA is the postinstall entrypoint for all platforms.
86
+ async function installDaemonCA(caPath, opts) {
87
+ const platform = (opts && opts.platform) || process.platform;
88
+ if (platform === 'darwin') {
89
+ return installMacOSCA(caPath, { ...opts, platform: 'darwin' });
90
+ }
91
+ if (platform === 'linux') {
92
+ return installLinuxCA(caPath, opts);
93
+ }
94
+ const stderr = (opts && opts.stderr) || process.stderr;
95
+ logMsg(
96
+ stderr,
97
+ `Skalpel: Codex MITM CA install skipped on ${platform}; trust-store install ` +
98
+ 'not yet implemented for this OS.'
99
+ );
100
+ return { ok: true, skipped: true, reason: 'platform' };
101
+ }
102
+
103
+ // installLinuxCA copies the daemon CA into the distro trust bundle and
104
+ // refreshes the system store.
105
+ async function installLinuxCA(caPath, opts) {
56
106
  const o = opts || {};
57
- const spawn = o.spawn || childProcess.spawn;
58
107
  const stderr = o.stderr || process.stderr;
59
- const homedir = o.homedir || os.homedir();
60
- const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS;
108
+ const spawnSync = o.spawnSync || childProcess.spawnSync;
61
109
  const platform = o.platform || process.platform;
62
110
 
63
- if (platform !== 'darwin') {
111
+ if (platform !== 'linux') {
112
+ return { ok: true, skipped: true, reason: 'platform' };
113
+ }
114
+
115
+ if (!fs.existsSync(caPath)) {
116
+ return writeDeferredSentinel(caPath, o, stderr);
117
+ }
118
+
119
+ const dest = o.linuxTargetPath || linuxTargetPath();
120
+ if (fs.existsSync(dest) && caFilesMatch(caPath, dest)) {
121
+ return { ok: true, skipped: true, reason: 'already-installed' };
122
+ }
123
+
124
+ if (!isRootUser() && !sudoAvailable(spawnSync)) {
64
125
  logMsg(
65
126
  stderr,
66
- `Skalpel: Codex MITM CA install skipped on ${platform}; Linux/Windows ` +
67
- `trust-store install not yet implemented. Codex will fall back to bare ` +
68
- `TLS — daemon won't see Codex traffic until trust is configured manually.`
127
+ 'Skalpel: Linux CA trust install needs root or passwordless sudo. Run:\n' +
128
+ ` sudo install -m 0644 ${caPath} ${dest}\n` +
129
+ ' sudo update-ca-certificates'
69
130
  );
70
- return { ok: true, skipped: true, reason: 'platform' };
131
+ return { ok: false, reason: 'no-sudo' };
71
132
  }
72
133
 
73
- if (!fs.existsSync(caPath)) {
74
- // Daemon hasn't generated the CA yet. Drop a sentinel and let the
75
- // Go-side retry path pick it up on next `skalpel login` /
76
- // `skalpel codex-exec`.
77
- const sentinelDir = o.sentinelDir || path.dirname(caPath);
78
- const sentinelPath = path.join(sentinelDir, SENTINEL_BASENAME);
79
- try {
80
- fs.mkdirSync(sentinelDir, { recursive: true });
81
- fs.writeFileSync(sentinelPath, '');
82
- } catch (err) {
83
- logMsg(stderr, `Skalpel: could not write ca-install sentinel at ${sentinelPath}: ${err.message}`);
84
- }
134
+ const installRes = runPrivileged(spawnSync, 'install', ['-m', '0644', caPath, dest]);
135
+ if (installRes.status !== 0) {
136
+ const detail = (installRes.stderr || installRes.stdout || '').trim();
85
137
  logMsg(
86
138
  stderr,
87
- 'Skalpel: daemon CA not yet generated will install on first `skalpel login` or `codex` invocation.'
139
+ `Skalpel: failed to copy CA to ${dest}${detail ? `: ${detail}` : ''}. ` +
140
+ 'Codex may bypass skalpel until trust is configured.'
88
141
  );
89
- return { ok: false, reason: 'deferred-no-ca', sentinelPath };
142
+ return { ok: false, reason: 'install-failed', exitCode: installRes.status, detail };
143
+ }
144
+
145
+ const updateRes = runPrivileged(spawnSync, 'update-ca-certificates', []);
146
+ if (updateRes.status !== 0) {
147
+ const detail = (updateRes.stderr || updateRes.stdout || '').trim();
148
+ logMsg(
149
+ stderr,
150
+ `Skalpel: update-ca-certificates failed${detail ? `: ${detail}` : ''}.`
151
+ );
152
+ return { ok: false, reason: 'update-failed', exitCode: updateRes.status, detail };
153
+ }
154
+
155
+ logMsg(stderr, 'Skalpel: daemon CA trusted in Linux system store.');
156
+ return { ok: true };
157
+ }
158
+
159
+ // installMacOSCA installs into the login keychain (darwin only).
160
+ async function installMacOSCA(caPath, opts) {
161
+ const o = opts || {};
162
+ const spawn = o.spawn || childProcess.spawn;
163
+ const stderr = o.stderr || process.stderr;
164
+ const homedir = o.homedir || os.homedir();
165
+ const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS;
166
+ const platform = o.platform || process.platform;
167
+
168
+ if (platform !== 'darwin') {
169
+ return installDaemonCA(caPath, opts);
170
+ }
171
+
172
+ if (!fs.existsSync(caPath)) {
173
+ return writeDeferredSentinel(caPath, o, stderr);
90
174
  }
91
175
 
92
176
  const loginKC = loginKeychainPath(homedir);
93
177
 
94
- // Idempotency: probe first via `security find-certificate -c <label>
95
- // <keychain>`. Exit 0 → already trusted; nothing to do.
96
178
  if (await findCertificate(spawn, loginKC, timeoutMs)) {
97
179
  return { ok: true, skipped: true, reason: 'already-installed' };
98
180
  }
99
181
 
100
- return await runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs);
182
+ return runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs);
101
183
  }
102
184
 
103
- // findCertificate runs `security find-certificate -c <label> <kc>` and
104
- // resolves to true iff the certificate is already trusted in the user's
105
- // login keychain.
106
185
  function findCertificate(spawn, loginKC, timeoutMs) {
107
186
  return new Promise((resolve) => {
108
187
  const child = spawn('security', ['find-certificate', '-c', KEYCHAIN_LABEL, loginKC], {
@@ -130,11 +209,6 @@ function findCertificate(spawn, loginKC, timeoutMs) {
130
209
  });
131
210
  }
132
211
 
133
- // runAddTrustedCert spawns the install command, branching on its
134
- // outcome:
135
- // exit 0 → installed
136
- // exit !=0 within timeout → user declined or system rejected
137
- // timeout → kill child and report timeout
138
212
  function runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs) {
139
213
  return new Promise((resolve) => {
140
214
  const child = spawn(
@@ -180,9 +254,15 @@ function runAddTrustedCert(spawn, stderr, caPath, loginKC, timeoutMs) {
180
254
  }
181
255
 
182
256
  module.exports = {
257
+ installDaemonCA,
183
258
  installMacOSCA,
259
+ installLinuxCA,
184
260
  KEYCHAIN_LABEL,
185
261
  SENTINEL_BASENAME,
186
- // Exported for tests:
262
+ LINUX_CA_DIR,
263
+ LINUX_CA_BASENAME,
264
+ linuxTargetPath,
187
265
  loginKeychainPath,
266
+ caFilesMatch,
267
+ runPrivileged,
188
268
  };
@@ -95,19 +95,118 @@ function tmpdir() {
95
95
  async function run() {
96
96
  process.stdout.write('ca-install tests:\n');
97
97
 
98
- await test('non-darwin skip', async () => {
98
+ await test('win32 platform skip via installDaemonCA', async () => {
99
99
  const stderr = captureStderr();
100
- const spawn = makeSpawn([]);
101
- const result = await ca.installMacOSCA('/anything', {
102
- spawn,
100
+ const result = await ca.installDaemonCA('/anything', {
103
101
  stderr,
104
- platform: 'linux',
102
+ platform: 'win32',
105
103
  });
106
104
  assert.strictEqual(result.ok, true);
107
105
  assert.strictEqual(result.skipped, true);
108
106
  assert.strictEqual(result.reason, 'platform');
109
- assert.strictEqual(spawn.calls.length, 0, 'must not invoke security on non-darwin');
110
- assert.ok(/linux/.test(stderr.text()), 'should mention current platform');
107
+ assert.ok(/win32/.test(stderr.text()), 'should mention current platform');
108
+ });
109
+
110
+ await test('linux deferred writes sentinel', async () => {
111
+ const stderr = captureStderr();
112
+ const dir = tmpdir();
113
+ const caPath = path.join(dir, 'mitm-ca.pem');
114
+ const result = await ca.installLinuxCA(caPath, {
115
+ stderr,
116
+ platform: 'linux',
117
+ sentinelDir: dir,
118
+ spawnSync: () => ({ status: 0 }),
119
+ });
120
+ assert.strictEqual(result.ok, false);
121
+ assert.strictEqual(result.reason, 'deferred-no-ca');
122
+ const sentinel = path.join(dir, ca.SENTINEL_BASENAME);
123
+ assert.ok(fs.existsSync(sentinel));
124
+ });
125
+
126
+ await test('linux already-installed idempotency', async () => {
127
+ const stderr = captureStderr();
128
+ const dir = tmpdir();
129
+ const caPath = path.join(dir, 'mitm-ca.pem');
130
+ const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
131
+ const body = 'test-pem';
132
+ fs.writeFileSync(caPath, body);
133
+ fs.writeFileSync(dest, body);
134
+ const spawnSync = () => {
135
+ throw new Error('spawnSync should not run when already installed');
136
+ };
137
+ const result = await ca.installLinuxCA(caPath, {
138
+ stderr,
139
+ platform: 'linux',
140
+ linuxTargetPath: dest,
141
+ spawnSync,
142
+ });
143
+ assert.strictEqual(result.ok, true);
144
+ assert.strictEqual(result.skipped, true);
145
+ assert.strictEqual(result.reason, 'already-installed');
146
+ });
147
+
148
+ await test('linux success path', async () => {
149
+ const stderr = captureStderr();
150
+ const dir = tmpdir();
151
+ const caPath = path.join(dir, 'mitm-ca.pem');
152
+ const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
153
+ fs.writeFileSync(caPath, 'pem');
154
+ const calls = [];
155
+ const spawnSync = (bin, args) => {
156
+ if (bin === 'sudo') {
157
+ calls.push({ bin: args[1], args: args.slice(2) });
158
+ } else {
159
+ calls.push({ bin, args });
160
+ }
161
+ if (calls[calls.length - 1].bin === 'install') {
162
+ fs.copyFileSync(caPath, dest);
163
+ }
164
+ return { status: 0, stdout: '', stderr: '' };
165
+ };
166
+ const origGetuid = process.getuid;
167
+ process.getuid = () => 1000;
168
+ try {
169
+ const result = await ca.installLinuxCA(caPath, {
170
+ stderr,
171
+ platform: 'linux',
172
+ linuxTargetPath: dest,
173
+ spawnSync,
174
+ });
175
+ assert.strictEqual(result.ok, true);
176
+ assert.ok(fs.existsSync(dest));
177
+ const bins = calls.map((c) => c.bin);
178
+ assert.ok(bins.includes('install'), `expected install in ${bins}`);
179
+ assert.ok(bins.includes('update-ca-certificates'), `expected update-ca-certificates in ${bins}`);
180
+ } finally {
181
+ process.getuid = origGetuid;
182
+ }
183
+ });
184
+
185
+ await test('linux no-sudo soft failure', async () => {
186
+ const stderr = captureStderr();
187
+ const dir = tmpdir();
188
+ const caPath = path.join(dir, 'mitm-ca.pem');
189
+ const dest = path.join(dir, 'skalpel-local-intercept-ca.crt');
190
+ fs.writeFileSync(caPath, 'pem');
191
+ const spawnSync = (bin) => {
192
+ if (bin === 'sudo') return { status: 1, stdout: '', stderr: 'sorry' };
193
+ return { status: 0, stdout: '', stderr: '' };
194
+ };
195
+ const origGetuid = process.getuid;
196
+ process.getuid = () => 1000;
197
+ try {
198
+ const result = await ca.installLinuxCA(caPath, {
199
+ stderr,
200
+ platform: 'linux',
201
+ linuxTargetPath: dest,
202
+ spawnSync,
203
+ });
204
+ assert.strictEqual(result.ok, false);
205
+ assert.strictEqual(result.reason, 'no-sudo');
206
+ assert.ok(/passwordless sudo|sudo install/.test(stderr.text()));
207
+ } finally {
208
+ process.getuid = origGetuid;
209
+ }
111
210
  });
112
211
 
113
212
  await test('already-installed idempotency', async () => {
@@ -16,12 +16,14 @@ const path = require('path');
16
16
  const log = require('./log');
17
17
 
18
18
  function run({ dryRun }) {
19
- const hint = 'all set — run `skalpel` to open the TUI';
19
+ const hint =
20
+ 'all set — run `skalpel` to open the guided TUI walkthrough (sign-in, daemon status, toggle tips)';
20
21
  if (dryRun) {
21
22
  log.dryRun(`step 5 launch: would print: ${hint}`);
22
23
  return { launched: false, dryRun: true };
23
24
  }
24
25
  log.info(hint);
26
+ log.info('npm may hide lifecycle output by default; use `npm install -g skalpel --foreground-scripts` if you want to see wizard logs.');
25
27
  return { launched: false, deferred: true };
26
28
  }
27
29