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 +1 -1
- package/src/commands/setup.js +66 -4
- package/test/setup-args.test.js +168 -1
package/package.json
CHANGED
package/src/commands/setup.js
CHANGED
|
@@ -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) => {
|
|
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)
|
|
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
|
-
|
|
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,
|
package/test/setup-args.test.js
CHANGED
|
@@ -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
|
+
});
|