unbound-cli 1.8.0 → 1.9.0

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": "unbound-cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -448,6 +448,45 @@ function removeBinaryInstall({
448
448
  return { removed, failed };
449
449
  }
450
450
 
451
+
452
+ // Run the binary's own `unbound-hook clear` to deregister managed configs
453
+ // (strip our hooks from shared configs, remove our scripts + per-tool logs)
454
+ // BEFORE the install tree is deleted. Essential on binary-only machines that
455
+ // have no python3 to run the downloaded per-tool clears, and authoritative for
456
+ // binary installs generally — it reuses the same clear_setup() as the python
457
+ // clears. Fail-open: never throws; returns { ran, ok }. `binaryPath`/`runner`
458
+ // are overridable so tests stay hermetic.
459
+ function runBinaryClear({
460
+ binaryPath = path.join(BINARY_INSTALL_PATHS.optDir, 'current', 'unbound-hook', 'unbound-hook'),
461
+ runner = spawnSync,
462
+ } = {}) {
463
+ // lstatSync (not existsSync, which follows symlinks) so a present-but-broken
464
+ // `current/...` symlink is still detected — we attempt the clear and surface
465
+ // the failure instead of silently skipping it. A truly absent path (python-only
466
+ // install) throws → no-op.
467
+ let present = false;
468
+ try { fs.lstatSync(binaryPath); present = true; } catch (e) { present = false; }
469
+ if (!present) return { ran: false, ok: true };
470
+ try {
471
+ // timeout so a hung clear can never block nuke from removing the binary.
472
+ const r = runner(binaryPath, ['clear'], { stdio: 'pipe', timeout: 120000, killSignal: 'SIGKILL' });
473
+ if (r.error) {
474
+ const why = r.error.code === 'ETIMEDOUT' ? 'timed out (120s)' : r.error.message;
475
+ output.warn(`nuke: unbound-hook clear ${why}; continuing.`);
476
+ return { ran: true, ok: false };
477
+ }
478
+ if (r.status !== 0) {
479
+ const stderr = r.stderr ? r.stderr.toString().trim() : '';
480
+ output.warn(`nuke: unbound-hook clear exited ${r.status}: ${stderr || '(no stderr)'}`);
481
+ return { ran: true, ok: false };
482
+ }
483
+ return { ran: true, ok: true };
484
+ } catch (err) {
485
+ output.warn(`nuke: unbound-hook clear errored: ${err.message}`);
486
+ return { ran: true, ok: false };
487
+ }
488
+ }
489
+
451
490
  /**
452
491
  * Returns true when the process has the privileges needed to touch system-level
453
492
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -1047,8 +1086,13 @@ Examples:
1047
1086
  // `_confirmProductionPaths` is the explicit "this is the real call site"
1048
1087
  // ack — the function refuses production defaults without it as a guard
1049
1088
  // against tests forgetting to override paths.
1089
+ // Deregister managed configs via the binary's own clear FIRST (works
1090
+ // without python3, which a binary-only machine lacks); reuses the same
1091
+ // clear_setup() as the python clears. Must precede the rm below.
1092
+ const binClear = runBinaryClear();
1050
1093
  const { removed: binaryRemoved, failed: bf } = removeBinaryInstall({ _confirmProductionPaths: true });
1051
1094
  binaryFailed = bf;
1095
+ if (binClear.ran && !binClear.ok) binaryFailed.push('unbound-hook clear');
1052
1096
  if (binaryRemoved.length) {
1053
1097
  output.info(`Removed binary install: ${binaryRemoved.join('; ')}.`);
1054
1098
  }
@@ -1141,5 +1185,6 @@ module.exports = {
1141
1185
  clearUnboundEnvsEverywhere,
1142
1186
  NUKE_ENV_VARS,
1143
1187
  removeBinaryInstall,
1188
+ runBinaryClear,
1144
1189
  BINARY_INSTALL_PATHS,
1145
1190
  };
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
7
7
 
8
8
  // shellEscape single-quotes every value, so a real key surfaces as
9
9
  // --api-key '<key>' at the head of the argv tail.
@@ -396,3 +396,58 @@ test('removeBinaryInstall: refuses production-default paths without _confirmProd
396
396
  test('BINARY_INSTALL_PATHS: is frozen', () => {
397
397
  assert.ok(Object.isFrozen(BINARY_INSTALL_PATHS));
398
398
  });
399
+
400
+ test('runBinaryClear: binary present — runs `<bin> clear` before removal, reports ok', () => {
401
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
402
+ const bin = path.join(tmp, 'unbound-hook');
403
+ fs.writeFileSync(bin, '#');
404
+ const calls = [];
405
+ try {
406
+ const r = runBinaryClear({ binaryPath: bin, runner: (b, args, opts) => { calls.push([b, args, opts]); return { status: 0 }; } });
407
+ assert.deepEqual(r, { ran: true, ok: true });
408
+ assert.equal(calls.length, 1, 'binary clear should run exactly once');
409
+ assert.equal(calls[0][0], bin);
410
+ assert.deepEqual(calls[0][1], ['clear']);
411
+ assert.ok(calls[0][2].timeout > 0, 'clear must run with a timeout so a hang cannot block nuke');
412
+ } finally {
413
+ fs.rmSync(tmp, { recursive: true, force: true });
414
+ }
415
+ });
416
+
417
+ test('runBinaryClear: dangling binary symlink — still attempts (surfaces, not silently skipped)', () => {
418
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
419
+ const link = path.join(tmp, 'unbound-hook');
420
+ fs.symlinkSync(path.join(tmp, 'missing-target'), link); // dangling: target absent
421
+ let called = false;
422
+ try {
423
+ const r = runBinaryClear({ binaryPath: link, runner: () => { called = true; return { error: { code: 'ENOENT', message: 'spawn ENOENT' } }; } });
424
+ assert.equal(called, true, 'a dangling symlink must still be attempted, not treated as absent');
425
+ assert.deepEqual(r, { ran: true, ok: false });
426
+ } finally {
427
+ fs.rmSync(tmp, { recursive: true, force: true });
428
+ }
429
+ });
430
+
431
+ test('runBinaryClear: binary absent (python-only install) — no-op, runner never called', () => {
432
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
433
+ let called = false;
434
+ try {
435
+ const r = runBinaryClear({ binaryPath: path.join(tmp, 'nope'), runner: () => { called = true; return { status: 0 }; } });
436
+ assert.deepEqual(r, { ran: false, ok: true });
437
+ assert.equal(called, false);
438
+ } finally {
439
+ fs.rmSync(tmp, { recursive: true, force: true });
440
+ }
441
+ });
442
+
443
+ test('runBinaryClear: clear exits non-zero — fail-open (ran:true, ok:false), never throws', () => {
444
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
445
+ const bin = path.join(tmp, 'unbound-hook');
446
+ fs.writeFileSync(bin, '#');
447
+ try {
448
+ const r = runBinaryClear({ binaryPath: bin, runner: () => ({ status: 1, stderr: Buffer.from('boom') }) });
449
+ assert.deepEqual(r, { ran: true, ok: false });
450
+ } finally {
451
+ fs.rmSync(tmp, { recursive: true, force: true });
452
+ }
453
+ });