skalpel 3.0.6 → 3.0.8

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.
@@ -238,21 +238,66 @@ function runUninstall(rest) {
238
238
  if (cleanupData) args.push('--cleanup-data');
239
239
  if (dryRun) args.push('--dry-run');
240
240
 
241
+ // X4 fix: pass a temp summary file path; postinstall writes a JSON
242
+ // structure with per-phase counts so we can print a truthful
243
+ // message instead of unconditionally claiming success.
244
+ const summaryFile = path.join(
245
+ os.tmpdir(),
246
+ `skalpel-uninstall-summary-${process.pid}-${Date.now()}.json`
247
+ );
248
+ const childEnv = Object.assign({}, process.env, {
249
+ SKALPEL_UNINSTALL_SUMMARY_FILE: summaryFile,
250
+ });
251
+
241
252
  const result = spawnSync(process.execPath, [postinstall, ...args], {
242
253
  stdio: 'inherit',
254
+ env: childEnv,
243
255
  });
244
256
  if (result.error) {
257
+ try { fs.rmSync(summaryFile, { force: true }); } catch (_) {}
245
258
  emitError(process.stderr, 'Uninstall failed', result.error.message, '→ Try `npm uninstall -g skalpel`.');
246
259
  return 1;
247
260
  }
248
261
  if (result.status !== 0) {
262
+ try { fs.rmSync(summaryFile, { force: true }); } catch (_) {}
249
263
  return result.status === null ? 1 : result.status;
250
264
  }
251
265
 
266
+ let summary = null;
267
+ try {
268
+ summary = JSON.parse(fs.readFileSync(summaryFile, 'utf8'));
269
+ } catch (_) {
270
+ // Older postinstall didn't write one, or file removed mid-flight.
271
+ } finally {
272
+ try { fs.rmSync(summaryFile, { force: true }); } catch (_) {}
273
+ }
274
+
252
275
  const colored = process.stdout.isTTY && !process.env.NO_COLOR;
253
276
  const dim = colored ? theme.dim : (s) => s;
254
277
  const ok = colored ? theme.mint('✓') : '✓';
255
- process.stdout.write(`\n${ok} skalpel state removed from this machine.\n`);
278
+
279
+ if (summary) {
280
+ const rc = summary.rcBlocksRemoved | 0;
281
+ const svc = summary.serviceFileRemoved ? 1 : 0;
282
+ const data = summary.userDataFilesRemoved | 0;
283
+ const total = rc + svc + data;
284
+ if (total === 0 && !summary.dryRun) {
285
+ process.stdout.write(`\n${ok} skalpel state was already clean — nothing to remove.\n`);
286
+ } else {
287
+ const parts = [
288
+ `${rc} shell-rc block${rc === 1 ? '' : 's'}`,
289
+ `${svc} service entr${svc === 1 ? 'y' : 'ies'}`,
290
+ ];
291
+ if (summary.cleanupDataRequested) {
292
+ parts.push(`${data} user-data file${data === 1 ? '' : 's'}`);
293
+ }
294
+ const prefix = summary.dryRun ? '[dry-run] would remove' : 'Removed';
295
+ process.stdout.write(`\n${ok} ${prefix}: ${parts.join(', ')}.\n`);
296
+ }
297
+ } else {
298
+ // Fallback to the old unconditional message if no summary made it back.
299
+ process.stdout.write(`\n${ok} skalpel state removed from this machine.\n`);
300
+ }
256
301
  process.stdout.write(`${dim('To finish removal, also run:')}\n`);
257
302
  process.stdout.write(` npm uninstall -g skalpel\n`);
258
303
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.0.6",
3
+ "version": "3.0.8",
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",
@@ -54,10 +54,10 @@
54
54
  "x64"
55
55
  ],
56
56
  "optionalDependencies": {
57
- "@skalpelai/skalpel-darwin-arm64": "3.0.6",
58
- "@skalpelai/skalpel-darwin-x64": "3.0.6",
59
- "@skalpelai/skalpel-linux-arm64": "3.0.6",
60
- "@skalpelai/skalpel-linux-x64": "3.0.6",
61
- "@skalpelai/skalpel-win32-x64": "3.0.6"
57
+ "@skalpelai/skalpel-darwin-arm64": "3.0.8",
58
+ "@skalpelai/skalpel-darwin-x64": "3.0.8",
59
+ "@skalpelai/skalpel-linux-arm64": "3.0.8",
60
+ "@skalpelai/skalpel-linux-x64": "3.0.8",
61
+ "@skalpelai/skalpel-win32-x64": "3.0.8"
62
62
  }
63
63
  }
@@ -110,60 +110,113 @@ function helpText() {
110
110
  }
111
111
 
112
112
  // B29: best-effort delete of user data on --uninstall --cleanup-data.
113
- // Skipped silently when files / dirs are absent.
113
+ // X5 fix: previously silent on absent files. Now logs "absent — skip"
114
+ // for every checked path so the user can see what was inspected.
115
+ // X7 fix: also clean cache/, skalpel-tui.log, skalpeld.log, socket
116
+ // markers, and stats.cache — the pre-X7 list was just 4 paths and
117
+ // left those behind, defeating the "full wipe" promise.
114
118
  function cleanupUserData({ dryRun }) {
115
119
  const removed = [];
116
- const targets = [
117
- paths.authFile(),
118
- paths.configToml(),
119
- paths.lockFile(),
120
+ const skipped = [];
121
+ const cfg = paths.configDir();
122
+ const fileTargets = [
123
+ paths.authFile(), // auth.json
124
+ paths.configToml(), // config.toml
125
+ paths.lockFile(), // skalpeld.lock
126
+ path.join(cfg, 'skalpel-tui.log'), // X7: TUI logger sink
127
+ path.join(cfg, 'skalpeld.log'), // X7: daemon log
128
+ path.join(cfg, 'skalpeld.sock'), // X7: daemon socket
129
+ path.join(cfg, 'skalpeld.sock.path'), // X7: socket path marker
130
+ path.join(cfg, 'stats.cache'), // X7: stats cache
131
+ ];
132
+ const dirTargets = [
133
+ paths.logsDir(), // logs/
134
+ path.join(cfg, 'cache'), // X7: model-fingerprint cache
120
135
  ];
121
- for (const t of targets) {
136
+
137
+ for (const t of fileTargets) {
138
+ const present = fs.existsSync(t);
122
139
  if (dryRun) {
123
- log.dryRun(`uninstall-cleanup: would rm ${t}`);
124
- removed.push(t);
140
+ if (present) {
141
+ log.dryRun(`uninstall-cleanup: would rm ${t}`);
142
+ removed.push(t);
143
+ } else {
144
+ log.dryRun(`uninstall-cleanup: ${t} absent — would skip`);
145
+ skipped.push(t);
146
+ }
125
147
  continue;
126
148
  }
127
149
  try {
128
- if (fs.existsSync(t)) {
150
+ if (present) {
129
151
  fs.rmSync(t, { force: true });
130
152
  log.info(`uninstall-cleanup: removed ${t}`);
131
153
  removed.push(t);
154
+ } else {
155
+ log.info(`uninstall-cleanup: ${t} absent — skip`);
156
+ skipped.push(t);
132
157
  }
133
158
  } catch (err) {
134
159
  log.warn(`uninstall-cleanup: rm ${t} failed: ${err.message}`);
135
160
  }
136
161
  }
137
- const lDir = paths.logsDir();
138
- if (dryRun) {
139
- log.dryRun(`uninstall-cleanup: would rm -r ${lDir}`);
140
- removed.push(lDir);
141
- } else {
162
+ for (const d of dirTargets) {
163
+ const present = fs.existsSync(d);
164
+ if (dryRun) {
165
+ if (present) {
166
+ log.dryRun(`uninstall-cleanup: would rm -r ${d}`);
167
+ removed.push(d);
168
+ } else {
169
+ log.dryRun(`uninstall-cleanup: ${d} absent — would skip`);
170
+ skipped.push(d);
171
+ }
172
+ continue;
173
+ }
142
174
  try {
143
- if (fs.existsSync(lDir)) {
144
- fs.rmSync(lDir, { recursive: true, force: true });
145
- log.info(`uninstall-cleanup: removed ${lDir}`);
146
- removed.push(lDir);
175
+ if (present) {
176
+ fs.rmSync(d, { recursive: true, force: true });
177
+ log.info(`uninstall-cleanup: removed ${d}`);
178
+ removed.push(d);
179
+ } else {
180
+ log.info(`uninstall-cleanup: ${d} absent — skip`);
181
+ skipped.push(d);
147
182
  }
148
183
  } catch (err) {
149
- log.warn(`uninstall-cleanup: rm -r ${lDir} failed: ${err.message}`);
184
+ log.warn(`uninstall-cleanup: rm -r ${d} failed: ${err.message}`);
150
185
  }
151
186
  }
152
- // Try to remove the (now-empty) configDir; ignore ENOTEMPTY which
153
- // means the user has other state under it we don't own.
154
- const cfg = paths.configDir();
187
+ // Try to remove the (now-empty) configDir; ENOTEMPTY means the
188
+ // user has third-party state under it we deliberately did not
189
+ // touch leave it alone.
155
190
  if (!dryRun) {
156
191
  try {
157
192
  fs.rmdirSync(cfg);
158
193
  log.info(`uninstall-cleanup: removed empty ${cfg}`);
159
194
  removed.push(cfg);
160
- } catch (_) {
161
- // non-empty or missing — leave alone.
195
+ } catch (err) {
196
+ if (err.code === 'ENOTEMPTY') {
197
+ log.info(`uninstall-cleanup: ${cfg} not empty (third-party files preserved) — skip rmdir`);
198
+ } else if (err.code !== 'ENOENT') {
199
+ log.warn(`uninstall-cleanup: rmdir ${cfg} failed: ${err.message}`);
200
+ }
162
201
  }
163
202
  } else {
164
203
  log.dryRun(`uninstall-cleanup: would rmdir (if empty) ${cfg}`);
165
204
  }
166
- return { removed };
205
+ return { removed, skipped };
206
+ }
207
+
208
+ // X4 fix: write a JSON summary of what the uninstall actually did to
209
+ // the path in SKALPEL_UNINSTALL_SUMMARY_FILE, so npm-bin/skalpel.js
210
+ // (which inherited stdio to this child) can derive a truthful count
211
+ // summary without parsing log lines.
212
+ function writeSummaryFile(summary) {
213
+ const out = process.env.SKALPEL_UNINSTALL_SUMMARY_FILE;
214
+ if (!out) return;
215
+ try {
216
+ fs.writeFileSync(out, JSON.stringify(summary), { mode: 0o600 });
217
+ } catch (err) {
218
+ log.warn(`uninstall: failed to write summary to ${out}: ${err.message}`);
219
+ }
167
220
  }
168
221
 
169
222
  function main(argv) {
@@ -192,21 +245,44 @@ function main(argv) {
192
245
  const allWarnings = [];
193
246
 
194
247
  if (opts.uninstall) {
248
+ // X4 fix: count what was actually removed in each phase so the
249
+ // shim can print a truthful summary instead of a blanket "✓ state
250
+ // removed" that lies when the system was already clean.
251
+ const summary = {
252
+ rcBlocksRemoved: 0,
253
+ serviceFileRemoved: false,
254
+ serviceUnloadSucceeded: 0,
255
+ serviceUnloadFailedAllowed: 0,
256
+ serviceUnloadSkipped: false,
257
+ userDataFilesRemoved: 0,
258
+ userDataFilesSkipped: 0,
259
+ cleanupDataRequested: !!opts.cleanupData,
260
+ dryRun: !!opts.dryRun,
261
+ errors: [],
262
+ };
195
263
  try {
196
264
  const stepCount = opts.cleanupData ? 3 : 2;
197
265
  log.step(1, stepCount, 'env-uninject', 'remove managed-block from rc files');
198
- envInject.uninject({ dryRun: opts.dryRun });
266
+ const ei = envInject.uninject({ dryRun: opts.dryRun }) || {};
267
+ summary.rcBlocksRemoved = Array.isArray(ei.removed) ? ei.removed.length : 0;
199
268
 
200
269
  log.step(2, stepCount, 'service-unregister', `OS=${process.platform}`);
201
270
  const ur = serviceRegister.unregister({ dryRun: opts.dryRun }) || {};
202
271
  if (Array.isArray(ur.errors) && ur.errors.length) {
203
272
  allWarnings.push(...ur.errors);
273
+ summary.errors.push(...ur.errors);
204
274
  }
275
+ summary.serviceFileRemoved = !!ur.serviceFileRemoved;
276
+ summary.serviceUnloadSucceeded = ur.succeeded || 0;
277
+ summary.serviceUnloadFailedAllowed = ur.failedAllowed || 0;
278
+ summary.serviceUnloadSkipped = !!ur.skipped;
205
279
 
206
280
  // B29: optional user-data cleanup.
207
281
  if (opts.cleanupData) {
208
- log.step(3, stepCount, 'cleanup-user-data', 'delete auth.json/config.toml/lock/logs');
209
- cleanupUserData({ dryRun: opts.dryRun });
282
+ log.step(3, stepCount, 'cleanup-user-data', 'delete auth.json/config.toml/lock/logs/cache');
283
+ const cd = cleanupUserData({ dryRun: opts.dryRun }) || {};
284
+ summary.userDataFilesRemoved = Array.isArray(cd.removed) ? cd.removed.length : 0;
285
+ summary.userDataFilesSkipped = Array.isArray(cd.skipped) ? cd.skipped.length : 0;
210
286
  } else {
211
287
  log.info(
212
288
  'uninstall: user data preserved (auth.json/config.toml/lock/logs). ' +
@@ -218,8 +294,11 @@ function main(argv) {
218
294
  if (opts.verbose) {
219
295
  process.stderr.write(`${err.stack}\n`);
220
296
  }
297
+ summary.errors.push(err.message);
298
+ writeSummaryFile(summary);
221
299
  return 0;
222
300
  }
301
+ writeSummaryFile(summary);
223
302
  if (allWarnings.length) {
224
303
  log.warn(`postinstall wizard finished (${mode} uninstall) with ${allWarnings.length} warning(s)`);
225
304
  } else {
@@ -113,6 +113,13 @@ function uninject({ dryRun }) {
113
113
  if (r.changed) {
114
114
  log.info(`uninstall: removed managed block from ${rc.path} (${rc.shell})`);
115
115
  removed.push(rc.path);
116
+ } else {
117
+ // X6 fix: previously silent. Surface "checked, nothing to remove"
118
+ // so the user can tell the rc file was inspected — Owen's #discord
119
+ // 2026-05-12 Windows report only logged "powershell-legacy absent"
120
+ // and never mentioned the modern powershell rc which existed but
121
+ // had no managed block.
122
+ log.info(`uninstall: no managed block in ${rc.path} (${rc.shell}) — skip`);
116
123
  }
117
124
  }
118
125
  return { removed };
@@ -161,58 +161,119 @@ function verifyBinary() {
161
161
  return { bin, stat };
162
162
  }
163
163
 
164
- // B50: two-phase unregister with rollback markers. Phase 1 collects
165
- // what would be undone; phase 2 executes each step and on failure
166
- // runs the prior-step undo so the system does not end up in a
167
- // half-detached state.
164
+ // fallbackUnloadCommands returns commands that do not depend on
165
+ // installed-state files. Used when the per-OS primary unload path
166
+ // requires a precondition (rendered PS1, etc.) that may be missing
167
+ // e.g. an aborted install or a prior uninstall already deleted it.
168
+ function fallbackUnloadCommands() {
169
+ switch (process.platform) {
170
+ case 'win32':
171
+ // schtasks.exe lives in System32 and is always present; the
172
+ // PS1 helper only existed to template params, so we invoke
173
+ // schtasks directly when the helper is gone.
174
+ return [
175
+ ['schtasks.exe', ['/End', '/TN', 'Skalpel\\skalpel-daemon'], { allowFail: true }],
176
+ ['schtasks.exe', ['/Delete', '/TN', 'Skalpel\\skalpel-daemon', '/F'], { allowFail: true }],
177
+ ];
178
+ default:
179
+ return [];
180
+ }
181
+ }
182
+
183
+ // Preflight + three-state outcome tracking. We distinguish:
184
+ // - succeeded: command exited 0
185
+ // - skipped-precondition: a file the command needs is absent
186
+ // - failed-allowed: command exited non-0, allowFail=true
187
+ // - failed-hard: command exited non-0, allowFail=false
188
+ // The pre-X1 code lumped succeeded + failed-allowed into "ok",
189
+ // producing "1/1 ok" reports when an unregister silently failed
190
+ // because the service was never registered to begin with. Owen
191
+ // (#discord, 2026-05-12) saw this on Windows when the rendered PS1
192
+ // helper was gone and PowerShell errored loudly while the wizard
193
+ // still reported success.
168
194
  function unregister({ dryRun }) {
169
195
  const dest = paths.servicePath();
196
+ const ps1 = renderedPowerShellPath();
197
+ const servicePresent = fs.existsSync(dest);
198
+ const ps1Present = fs.existsSync(ps1);
199
+
170
200
  if (dryRun) {
171
201
  log.dryRun(`uninstall: would unregister service at ${dest}`);
172
- for (const [bin, args] of unloadCommands()) {
173
- log.dryRun(` would run: ${bin} ${args.join(' ')}`);
202
+ if (!servicePresent) {
203
+ log.dryRun(` service file absent at ${dest} would skip unload commands`);
204
+ } else {
205
+ const cmds = (process.platform === 'win32' && !ps1Present)
206
+ ? fallbackUnloadCommands()
207
+ : unloadCommands();
208
+ for (const [bin, args] of cmds) {
209
+ log.dryRun(` would run: ${bin} ${args.join(' ')}`);
210
+ }
174
211
  }
175
212
  log.dryRun(` would rm ${dest}`);
176
- return { dryRun: true };
213
+ if (process.platform === 'win32') {
214
+ log.dryRun(` would rm ${ps1}`);
215
+ }
216
+ return { dryRun: true, succeeded: 0, skipped: 0, failedAllowed: 0, serviceFileRemoved: false };
217
+ }
218
+
219
+ let cmds;
220
+ if (!servicePresent) {
221
+ log.info(`uninstall: service file absent at ${dest} — nothing registered to unload`);
222
+ cmds = [];
223
+ } else if (process.platform === 'win32' && !ps1Present) {
224
+ log.warn(`uninstall: PowerShell helper absent at ${ps1} — using direct schtasks fallback`);
225
+ cmds = fallbackUnloadCommands();
226
+ } else {
227
+ cmds = unloadCommands();
177
228
  }
178
229
 
179
- // Phase 1: plan the steps.
180
- const steps = unloadCommands().map(([bin, args, opts]) => ({
181
- bin,
182
- args,
183
- allowFail: !!(opts && opts.allowFail),
184
- done: false,
185
- }));
230
+ let succeeded = 0;
231
+ let failedAllowed = 0;
186
232
  const errors = [];
187
233
 
188
- // Phase 2: execute. On hard failure, log every prior step (we do not
189
- // attempt to re-bootstrap because that would re-install the daemon).
190
- for (const step of steps) {
191
- const r = spawnSync(step.bin, step.args, { stdio: 'inherit' });
192
- if (r.status !== 0 && !step.allowFail) {
193
- const msg = `uninstall: ${step.bin} ${step.args.join(' ')} exited ${r.status}`;
234
+ for (const [bin, args, opts] of cmds) {
235
+ const allowFail = !!(opts && opts.allowFail);
236
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
237
+ if (r.status === 0) {
238
+ succeeded += 1;
239
+ } else if (allowFail) {
240
+ failedAllowed += 1;
241
+ log.info(`uninstall: ${bin} ${args.join(' ')} exited ${r.status} (allowed)`);
242
+ } else {
243
+ const msg = `uninstall: ${bin} ${args.join(' ')} exited ${r.status}`;
194
244
  errors.push(msg);
195
245
  log.warn(`${msg}; rolling forward to file removal`);
196
- } else {
197
- step.done = true;
198
246
  }
199
247
  }
200
- log.info(
201
- `uninstall: phase 2 complete: ${steps.filter((s) => s.done).length}/${steps.length} ok` +
202
- (errors.length ? ` (${errors.length} errors)` : '')
203
- );
204
248
 
205
- if (fs.existsSync(dest)) {
249
+ if (cmds.length === 0) {
250
+ log.info('uninstall: phase 2 skipped — service was not registered');
251
+ } else {
252
+ log.info(
253
+ `uninstall: phase 2 complete: ${succeeded} succeeded, ` +
254
+ `${failedAllowed} failed (allowed)` +
255
+ (errors.length ? `, ${errors.length} hard errors` : '')
256
+ );
257
+ }
258
+
259
+ let serviceFileRemoved = false;
260
+ if (servicePresent) {
206
261
  fs.unlinkSync(dest);
207
262
  log.info(`uninstall: removed ${dest}`);
263
+ serviceFileRemoved = true;
208
264
  }
209
- // Also remove the rendered helper PS1 on Windows.
210
- const ps1 = renderedPowerShellPath();
211
- if (process.platform === 'win32' && fs.existsSync(ps1)) {
265
+ if (process.platform === 'win32' && ps1Present) {
212
266
  fs.unlinkSync(ps1);
213
267
  log.info(`uninstall: removed ${ps1}`);
214
268
  }
215
- return { removed: true, errors };
269
+ return {
270
+ removed: serviceFileRemoved,
271
+ serviceFileRemoved,
272
+ succeeded,
273
+ skipped: cmds.length === 0 ? 1 : 0,
274
+ failedAllowed,
275
+ errors,
276
+ };
216
277
  }
217
278
 
218
279
  function run({ dryRun }) {
@@ -290,4 +351,4 @@ function run({ dryRun }) {
290
351
  return { skipped: false, registered: true, warnings };
291
352
  }
292
353
 
293
- module.exports = { run, unregister, templatePath, loadCommands, unloadCommands, launchdTarget };
354
+ module.exports = { run, unregister, templatePath, loadCommands, unloadCommands, fallbackUnloadCommands, launchdTarget };