skalpel 3.0.7 → 3.0.9

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,84 @@ 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
+ }
301
+
302
+ // Only emit the "running agents still have stale env" hint when
303
+ // something was actually removed (or in dry-run); on already-clean
304
+ // re-runs the hint adds noise without value.
305
+ const ranAnyCleanup = summary && !(
306
+ summary.rcBlocksRemoved === 0 &&
307
+ !summary.serviceFileRemoved &&
308
+ summary.userDataFilesRemoved === 0
309
+ );
310
+ if (!summary || ranAnyCleanup) {
311
+ process.stdout.write(
312
+ `${dim('Note: any open shells or running coding agents (Claude Code, Codex, ' +
313
+ 'Cursor, etc.) still have the skalpel proxy env vars cached in their ' +
314
+ 'environment. Open a fresh shell and restart any active agents to drop ' +
315
+ 'them.')}\n`
316
+ );
317
+ }
318
+
256
319
  process.stdout.write(`${dim('To finish removal, also run:')}\n`);
257
320
  process.stdout.write(` npm uninstall -g skalpel\n`);
258
321
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.0.7",
3
+ "version": "3.0.9",
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.7",
58
- "@skalpelai/skalpel-darwin-x64": "3.0.7",
59
- "@skalpelai/skalpel-linux-arm64": "3.0.7",
60
- "@skalpelai/skalpel-linux-x64": "3.0.7",
61
- "@skalpelai/skalpel-win32-x64": "3.0.7"
57
+ "@skalpelai/skalpel-darwin-arm64": "3.0.9",
58
+ "@skalpelai/skalpel-darwin-x64": "3.0.9",
59
+ "@skalpelai/skalpel-linux-arm64": "3.0.9",
60
+ "@skalpelai/skalpel-linux-x64": "3.0.9",
61
+ "@skalpelai/skalpel-win32-x64": "3.0.9"
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) {
@@ -173,12 +226,35 @@ function main(argv) {
173
226
  return 0;
174
227
  }
175
228
 
176
- // B32: refuse to run under `npx skalpel`.
177
- if (paths.isNpxInvocation()) {
178
- process.stderr.write(
179
- 'Skalpel does not support `npx skalpel` please run `npm i -g @skalpelai/skalpel` instead.\n'
229
+ // npx mode: prior to v3.0.9 we returned exit 1 here, which caused
230
+ // npm to roll back the cached package install — leaving an empty
231
+ // ~/.npm/_npx/<hash>/ dir and a silent failure that re-prompted on
232
+ // every subsequent `npx skalpel` call. The original reason for the
233
+ // gate (B32) was that paths.binPath() couldn't locate the platform
234
+ // binary under npx's directory layout. That was fixed in v3.0.1
235
+ // when binPath was rewritten to use require.resolve against the
236
+ // platform sub-package (see paths.js:99-116) — which works under
237
+ // npx, global, or local install paths uniformly.
238
+ //
239
+ // We still skip the install wizard under npx because the wizard's
240
+ // service-register step would bake the npx cache path
241
+ // (~/.npm/_npx/<hash>/...) into the OS service unit, and that path
242
+ // is evicted by npx's cache GC. Exit 0 instead of 1 so npm install
243
+ // completes and the binary becomes invokable.
244
+ //
245
+ // Uninstall, however, MUST run under npx: a user running
246
+ // `npx skalpel uninstall` to clean up a prior `npm install -g`
247
+ // would otherwise hit the same skip and have nothing happen.
248
+ if (paths.isNpxInvocation() && !opts.uninstall) {
249
+ log.info(
250
+ 'npx mode detected — skipping install wizard (service-register / ' +
251
+ 'env-inject would bake transient npx cache paths into your system).'
252
+ );
253
+ log.info(
254
+ 'For a persistent install with daemon-on-boot and shell env vars, ' +
255
+ 'run: `npm install -g skalpel`'
180
256
  );
181
- return 1;
257
+ return 0;
182
258
  }
183
259
 
184
260
  const total = 5;
@@ -192,21 +268,44 @@ function main(argv) {
192
268
  const allWarnings = [];
193
269
 
194
270
  if (opts.uninstall) {
271
+ // X4 fix: count what was actually removed in each phase so the
272
+ // shim can print a truthful summary instead of a blanket "✓ state
273
+ // removed" that lies when the system was already clean.
274
+ const summary = {
275
+ rcBlocksRemoved: 0,
276
+ serviceFileRemoved: false,
277
+ serviceUnloadSucceeded: 0,
278
+ serviceUnloadFailedAllowed: 0,
279
+ serviceUnloadSkipped: false,
280
+ userDataFilesRemoved: 0,
281
+ userDataFilesSkipped: 0,
282
+ cleanupDataRequested: !!opts.cleanupData,
283
+ dryRun: !!opts.dryRun,
284
+ errors: [],
285
+ };
195
286
  try {
196
287
  const stepCount = opts.cleanupData ? 3 : 2;
197
288
  log.step(1, stepCount, 'env-uninject', 'remove managed-block from rc files');
198
- envInject.uninject({ dryRun: opts.dryRun });
289
+ const ei = envInject.uninject({ dryRun: opts.dryRun }) || {};
290
+ summary.rcBlocksRemoved = Array.isArray(ei.removed) ? ei.removed.length : 0;
199
291
 
200
292
  log.step(2, stepCount, 'service-unregister', `OS=${process.platform}`);
201
293
  const ur = serviceRegister.unregister({ dryRun: opts.dryRun }) || {};
202
294
  if (Array.isArray(ur.errors) && ur.errors.length) {
203
295
  allWarnings.push(...ur.errors);
296
+ summary.errors.push(...ur.errors);
204
297
  }
298
+ summary.serviceFileRemoved = !!ur.serviceFileRemoved;
299
+ summary.serviceUnloadSucceeded = ur.succeeded || 0;
300
+ summary.serviceUnloadFailedAllowed = ur.failedAllowed || 0;
301
+ summary.serviceUnloadSkipped = !!ur.skipped;
205
302
 
206
303
  // B29: optional user-data cleanup.
207
304
  if (opts.cleanupData) {
208
- log.step(3, stepCount, 'cleanup-user-data', 'delete auth.json/config.toml/lock/logs');
209
- cleanupUserData({ dryRun: opts.dryRun });
305
+ log.step(3, stepCount, 'cleanup-user-data', 'delete auth.json/config.toml/lock/logs/cache');
306
+ const cd = cleanupUserData({ dryRun: opts.dryRun }) || {};
307
+ summary.userDataFilesRemoved = Array.isArray(cd.removed) ? cd.removed.length : 0;
308
+ summary.userDataFilesSkipped = Array.isArray(cd.skipped) ? cd.skipped.length : 0;
210
309
  } else {
211
310
  log.info(
212
311
  'uninstall: user data preserved (auth.json/config.toml/lock/logs). ' +
@@ -218,8 +317,11 @@ function main(argv) {
218
317
  if (opts.verbose) {
219
318
  process.stderr.write(`${err.stack}\n`);
220
319
  }
320
+ summary.errors.push(err.message);
321
+ writeSummaryFile(summary);
221
322
  return 0;
222
323
  }
324
+ writeSummaryFile(summary);
223
325
  if (allWarnings.length) {
224
326
  log.warn(`postinstall wizard finished (${mode} uninstall) with ${allWarnings.length} warning(s)`);
225
327
  } 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 };