unbound-cli 1.11.0 → 1.11.1

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.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -209,11 +209,15 @@ async function runPythonScript(scriptPath, args, { capture }) {
209
209
  windowsHide: true,
210
210
  });
211
211
  let out = '';
212
+ // stderr is also kept on its own. Python block-buffers stdout when piped,
213
+ // so a crash's stdout can flush after the traceback and interleave with it
214
+ // in `out` — failureReason needs an uncontaminated stream to read.
215
+ let errOut = '';
212
216
  if (capture) {
213
217
  child.stdin.on('error', () => {});
214
218
  child.stdin.end();
215
219
  child.stdout.on('data', (d) => { out += d.toString(); });
216
- child.stderr.on('data', (d) => { out += d.toString(); });
220
+ child.stderr.on('data', (d) => { const s = d.toString(); out += s; errOut += s; });
217
221
  }
218
222
  child.on('close', (code) => {
219
223
  if (code === 0) return resolve();
@@ -223,7 +227,10 @@ async function runPythonScript(scriptPath, args, { capture }) {
223
227
  // Carry the numeric exit code so callers can report it to the failure
224
228
  // ledger (reportSetupFailure) without re-parsing the message.
225
229
  err.exitCode = code;
226
- if (capture) err.setupOutput = out.trim();
230
+ if (capture) {
231
+ err.setupOutput = out.trim();
232
+ err.stderr = errOut.trim();
233
+ }
227
234
  reject(err);
228
235
  });
229
236
  child.on('error', reject);
@@ -576,6 +583,61 @@ async function runBatch(tools, runFn, { clear = false, summary = null, report =
576
583
  return { ok: true, skipped: mdmSkipped };
577
584
  }
578
585
 
586
+ /**
587
+ * Picks the most useful one-line reason out of a failed setup script's captured
588
+ * output. The python scripts open AND close with a `====` banner, so the first
589
+ * line is never the reason a step failed — taking it verbatim produced the
590
+ * useless `Codex (gateway) (====================)` that hid a real bug. Prefer
591
+ * the last line the script itself flagged as an error, then the exit code.
592
+ *
593
+ * Tolerates a non-Error throw: this runs inside nuke's per-tool catch, so
594
+ * throwing here would abort the remaining tools instead of clearing them.
595
+ */
596
+ function failureReason(err) {
597
+ err = err || {};
598
+ // Read the traceback from stderr when we have it: `out` merges both streams by
599
+ // arrival order, so buffered stdout can land between the header and the
600
+ // exception. Fall back to the merged text for callers that don't set stderr.
601
+ const crash = exceptionLine(err.stderr) || exceptionLine(err.message);
602
+ if (crash) return truncate(crash);
603
+ const lines = (err.message || '').split('\n').map((l) => l.trim()).filter(Boolean);
604
+ // Otherwise the lines the scripts themselves flag. The dotted form catches a
605
+ // bare exception line captured without its traceback header.
606
+ const flagged = lines.filter((l) => /^(❌|failed\b|error\b|exception\b|[\w.]*(error|exception):)/i.test(l));
607
+ const pick = flagged.length ? flagged[flagged.length - 1] : null;
608
+ if (pick) return truncate(pick);
609
+ if (err.exitCode != null) return `exit code ${err.exitCode}`;
610
+ return truncate(lines[0] || 'failed');
611
+ }
612
+
613
+ /**
614
+ * The exception a python traceback ended on, or null if `text` has no traceback.
615
+ * Structural rather than positional: frame lines are indented, so the exception
616
+ * is the first UNindented line after the header. That holds even if buffered
617
+ * stdout flushed into the middle of the traceback. The LAST header wins, since
618
+ * with chained exceptions ("During handling of the above exception...") the final
619
+ * one is what actually propagated.
620
+ */
621
+ function exceptionLine(text) {
622
+ const raw = (text || '').split('\n');
623
+ let header = -1;
624
+ for (let i = 0; i < raw.length; i++) {
625
+ if (/^traceback\b/i.test(raw[i].trim())) header = i;
626
+ }
627
+ if (header === -1) return null;
628
+ const exc = raw.slice(header + 1).find(
629
+ (l) => l.trim() && !/^\s/.test(l) && !/^=+$/.test(l.trim())
630
+ );
631
+ return exc ? exc.trim() : null;
632
+ }
633
+
634
+ // Slice by code point, not code unit, so an 80-char cut can't split an emoji
635
+ // or other astral character into a broken fragment.
636
+ function truncate(s, max = 80) {
637
+ const chars = Array.from(s);
638
+ return chars.length > max ? chars.slice(0, max).join('') : s;
639
+ }
640
+
579
641
  /**
580
642
  * Clears a set of tools best-effort: a single tool failing does not abort the
581
643
  * rest (unlike runBatch, which stops on first failure). Used by `uninstall`,
@@ -592,8 +654,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
592
654
  await runScriptPiped(tool.script, args);
593
655
  s.succeed(`${layer}: ${tool.label}`);
594
656
  } catch (err) {
595
- const reason = (err.message || 'failed').split('\n')[0].slice(0, 80);
596
- s.fail(`${layer}: ${tool.label} (${reason})`);
657
+ s.fail(`${layer}: ${tool.label} (${failureReason(err)})`);
597
658
  failed.push(tool.label);
598
659
  }
599
660
  }
@@ -1183,6 +1244,7 @@ module.exports = {
1183
1244
  scriptSupportsBackfill,
1184
1245
  resolveSetupAllTools,
1185
1246
  clearUnboundEnvsEverywhere,
1247
+ failureReason,
1186
1248
  NUKE_ENV_VARS,
1187
1249
  removeBinaryInstall,
1188
1250
  runBinaryClear,
@@ -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, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, 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.
@@ -451,3 +451,170 @@ test('runBinaryClear: clear exits non-zero — fail-open (ran:true, ok:false), n
451
451
  fs.rmSync(tmp, { recursive: true, force: true });
452
452
  }
453
453
  });
454
+
455
+ // The python clears open AND close with a `====` banner, so the old
456
+ // `.split('\n')[0]` reason rendered `Codex (gateway) (==================)` —
457
+ // which is what hid the real MDM-clear bug. Prefer the script's own error line.
458
+ test('failureReason: skips the ==== banner and reports the script error line', () => {
459
+ const err = new Error([
460
+ '============================================================',
461
+ 'Codex - Clearing MDM Setup',
462
+ '============================================================',
463
+ '',
464
+ 'Clearing environment variables...',
465
+ 'Failed to clear openai_base_url in codex config for alice',
466
+ '',
467
+ 'Clear Complete!',
468
+ ].join('\n'));
469
+ err.exitCode = 1;
470
+ assert.equal(failureReason(err), 'Failed to clear openai_base_url in codex config for alice');
471
+ });
472
+
473
+ test('failureReason: picks the LAST flagged line when a script reports several', () => {
474
+ const err = new Error('Failed to clear API_KEY for 1 user(s)\nFailed to clear BASE_URL for 1 user(s)');
475
+ assert.equal(failureReason(err), 'Failed to clear BASE_URL for 1 user(s)');
476
+ });
477
+
478
+ test('failureReason: recognises the scripts’ ❌ prefix', () => {
479
+ const err = new Error('====\nSetup\n====\n❌ No user home directories found');
480
+ err.exitCode = 1;
481
+ assert.equal(failureReason(err), '❌ No user home directories found');
482
+ });
483
+
484
+ // Banner-only output with no error line must not surface "Clear Complete!" as
485
+ // the reason — the exit code is the honest answer there.
486
+ test('failureReason: no flagged line falls back to the exit code, not the banner', () => {
487
+ const err = new Error('============\nCodex - Clearing MDM Setup\n============\n\nClear Complete!');
488
+ err.exitCode = 2;
489
+ assert.equal(failureReason(err), 'exit code 2');
490
+ });
491
+
492
+ // Download failures carry no exitCode; their message is already the reason.
493
+ test('failureReason: download failure with no exitCode keeps its own message', () => {
494
+ const err = new Error('Download failed: 404 Not Found');
495
+ err.downloadFailed = true;
496
+ assert.equal(failureReason(err), 'Download failed: 404 Not Found');
497
+ });
498
+
499
+ test('failureReason: empty message never returns undefined', () => {
500
+ assert.equal(failureReason(new Error('')), 'failed');
501
+ assert.equal(failureReason({}), 'failed');
502
+ });
503
+
504
+ test('failureReason: long error lines are truncated to 80 chars', () => {
505
+ const err = new Error('Failed to ' + 'x'.repeat(200));
506
+ assert.equal(failureReason(err).length, 80);
507
+ });
508
+
509
+ // A python crash prints a traceback whose LAST line is the real cause. Falling
510
+ // back to `exit code 1` there would be less useful than the old first-line rule.
511
+ test('failureReason: python traceback reports the exception, not the header', () => {
512
+ const err = new Error([
513
+ 'Traceback (most recent call last):',
514
+ ' File "/tmp/setup.py", line 704, in clear_setup',
515
+ ' remove_codex_config_base_url_for_user(username, home_dir)',
516
+ "NameError: name 'home_dir' is not defined",
517
+ ].join('\n'));
518
+ err.exitCode = 1;
519
+ assert.equal(failureReason(err), "NameError: name 'home_dir' is not defined");
520
+ });
521
+
522
+ // exitCode 0 is falsy — must not fall through to the banner-first branch.
523
+ // Module-qualified exception names have dots, so a name-shaped regex misses
524
+ // them. The traceback tail is taken instead, whatever the exception is called.
525
+ test('failureReason: dotted exception name in a traceback is reported', () => {
526
+ const err = new Error([
527
+ 'Traceback (most recent call last):',
528
+ ' File "/tmp/setup.py", line 800, in clear_managed_hooks',
529
+ ' settings = json.load(f)',
530
+ 'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)',
531
+ ].join('\n'));
532
+ err.exitCode = 1;
533
+ assert.equal(failureReason(err), 'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)');
534
+ });
535
+
536
+ // Not every python exception name contains "Error"/"Exception".
537
+ test('failureReason: traceback tail works for a non-Error exception name', () => {
538
+ const err = new Error('Traceback (most recent call last):\n File "x.py", line 1\nsocket.timeout: timed out');
539
+ err.exitCode = 1;
540
+ assert.equal(failureReason(err), 'socket.timeout: timed out');
541
+ });
542
+
543
+ // A bare exception line captured without its traceback header still flags.
544
+ test('failureReason: dotted exception line without a traceback header flags', () => {
545
+ const err = new Error('====\nCodex - Clearing MDM Setup\n====\nsubprocess.CalledProcessError: returned non-zero exit status 1');
546
+ err.exitCode = 1;
547
+ assert.equal(failureReason(err), 'subprocess.CalledProcessError: returned non-zero exit status 1');
548
+ });
549
+
550
+ // Banner lines after the traceback must not be mistaken for the cause.
551
+ test('failureReason: banner trailing a traceback is skipped', () => {
552
+ const err = new Error('Traceback (most recent call last):\nValueError: bad\n============');
553
+ err.exitCode = 1;
554
+ assert.equal(failureReason(err), 'ValueError: bad');
555
+ });
556
+
557
+ // runPythonScript merges stdout+stderr by arrival order and python block-buffers
558
+ // stdout when piped, so a crash's progress lines can land AFTER the traceback.
559
+ // Picking the tail would report "Clear Complete!" as the failure reason.
560
+ test('failureReason: stdout flushed after the traceback does not displace the exception', () => {
561
+ const err = new Error([
562
+ 'Traceback (most recent call last):',
563
+ ' File "/tmp/setup.py", line 9, in clear_setup',
564
+ 'ValueError: the real cause',
565
+ '============================================================',
566
+ 'Clear Complete!',
567
+ ].join('\n'));
568
+ err.exitCode = 1;
569
+ assert.equal(failureReason(err), 'ValueError: the real cause');
570
+ });
571
+
572
+ // stderr is captured separately precisely so interleaving can't matter.
573
+ test('failureReason: prefers the clean stderr stream over the merged output', () => {
574
+ const err = new Error('====\nClearing environment variables...\nTraceback (most recent call last):\nOSError: merged and interleaved\nClear Complete!');
575
+ err.stderr = 'Traceback (most recent call last):\n File "x.py", line 1\nPermissionError: from stderr';
576
+ err.exitCode = 1;
577
+ assert.equal(failureReason(err), 'PermissionError: from stderr');
578
+ });
579
+
580
+ // Chained exceptions: the LAST traceback is the one that actually propagated.
581
+ test('failureReason: chained exceptions report the final one', () => {
582
+ const err = new Error([
583
+ 'Traceback (most recent call last):',
584
+ ' File "x.py", line 1',
585
+ 'ValueError: the first one',
586
+ 'During handling of the above exception, another exception occurred:',
587
+ 'Traceback (most recent call last):',
588
+ ' File "x.py", line 5',
589
+ 'RuntimeError: the one that propagated',
590
+ ].join('\n'));
591
+ err.exitCode = 1;
592
+ assert.equal(failureReason(err), 'RuntimeError: the one that propagated');
593
+ });
594
+
595
+ // Indented frame lines are never the reason.
596
+ test('failureReason: never reports an indented traceback frame line', () => {
597
+ const err = new Error('Traceback (most recent call last):\n File "x.py", line 1, in f\n boom()\nKeyError: \'gateway_url\'');
598
+ err.exitCode = 1;
599
+ assert.equal(failureReason(err), "KeyError: 'gateway_url'");
600
+ });
601
+
602
+ test('failureReason: exitCode 0 is reported, not treated as absent', () => {
603
+ assert.equal(failureReason({ message: '====\nCodex\n====', exitCode: 0 }), 'exit code 0');
604
+ });
605
+
606
+ // Truncation slices code points, so an emoji at the boundary stays intact.
607
+ test('failureReason: truncation never splits an astral character', () => {
608
+ const reason = failureReason(new Error('Failed ' + '🔥'.repeat(100)));
609
+ assert.equal(Array.from(reason).length, 80);
610
+ assert.ok(!reason.includes('\ufffd'), reason);
611
+ assert.ok(/🔥$/.test(reason), reason);
612
+ });
613
+
614
+ // Runs inside nuke's per-tool catch — throwing here would abort the remaining
615
+ // tools instead of clearing them, so a non-Error throw must not blow up.
616
+ test('failureReason: non-Error throw (null/undefined) never throws', () => {
617
+ assert.equal(failureReason(null), 'failed');
618
+ assert.equal(failureReason(undefined), 'failed');
619
+ assert.equal(failureReason('a string'), 'failed');
620
+ });