unbound-cli 1.6.3 → 1.6.5

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.
@@ -1,13 +1,14 @@
1
- const { execSync, spawn, spawnSync } = require('child_process');
1
+ const { spawn, spawnSync } = require('child_process');
2
2
  const { Option } = require('commander');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const https = require('https');
7
6
  const config = require('../config');
8
7
  const output = require('../output');
8
+ const telemetry = require('../telemetry');
9
9
  const { ensureLoggedIn } = require('../auth');
10
- const { confirm } = require('../utils');
10
+ const { confirm, downloadToFile, withSecureTempFile } = require('../utils');
11
+ const { reportSetupFailure, exitCodeForError } = require('../setup-report');
11
12
 
12
13
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
13
14
 
@@ -120,16 +121,7 @@ function shellEscape(str) {
120
121
  }
121
122
 
122
123
  /**
123
- * Builds a shell command that curls a setup script and pipes it to python3.
124
- * Used on macOS/Linux/WSL. Native Windows takes the runPythonScriptWindows path.
125
- */
126
- function buildSetupCommand(scriptPath, args) {
127
- const url = `${SETUP_BASE_URL}/${scriptPath}`;
128
- return `curl -fsSL "${url}" | python3 - ${args}`;
129
- }
130
-
131
- /**
132
- * Reverses shellEscape back into an argv list so the Windows path can call
124
+ * Reverses shellEscape back into an argv list so the script runner can call
133
125
  * spawn() directly without going through a shell. Handles unquoted tokens and
134
126
  * single-quoted strings with '\'' for embedded quotes (the exact format
135
127
  * shellEscape produces).
@@ -154,64 +146,59 @@ function parsePosixArgs(s) {
154
146
  }
155
147
 
156
148
  /**
157
- * Downloads a URL to a local file. Follows one level of redirect (GitHub raw
158
- * occasionally 302s). Used only by the Windows execution path.
149
+ * Resolves the Python launcher to use. On native Windows prefers the `py`
150
+ * launcher (installed by python.org at C:\Windows\py.exe no spaces in PATH),
151
+ * then `python`. On macOS/Linux/WSL prefers `python3`, then `python`.
159
152
  */
160
- function downloadToFile(url, destPath) {
161
- return new Promise((resolve, reject) => {
162
- const request = (u, remaining) => {
163
- https.get(u, (res) => {
164
- if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
165
- res.resume();
166
- return request(res.headers.location, remaining - 1);
167
- }
168
- if (res.statusCode !== 200) {
169
- res.resume();
170
- return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
171
- }
172
- const file = fs.createWriteStream(destPath);
173
- res.pipe(file);
174
- file.on('finish', () => file.close(resolve));
175
- file.on('error', reject);
176
- }).on('error', reject);
177
- };
178
- request(url, 3);
179
- });
180
- }
181
-
182
- /**
183
- * Resolves the Python launcher to use on native Windows.
184
- * Prefers the `py` launcher (installed by python.org at C:\Windows\py.exe —
185
- * no spaces in PATH), falls back to `python` on PATH.
186
- */
187
- function resolveWindowsPython() {
153
+ function resolvePython() {
188
154
  const probe = (cmd, args) => {
189
155
  try {
190
156
  const r = spawnSync(cmd, args, { stdio: 'ignore', shell: false, windowsHide: true });
191
157
  return r.status === 0;
192
158
  } catch { return false; }
193
159
  };
194
- if (probe('py', ['-3', '--version'])) return { cmd: 'py', prefix: ['-3'] };
195
- if (probe('python', ['--version'])) return { cmd: 'python', prefix: [] };
160
+ if (isWindowsNative()) {
161
+ if (probe('py', ['-3', '--version'])) return { cmd: 'py', prefix: ['-3'] };
162
+ if (probe('python', ['--version'])) return { cmd: 'python', prefix: [] };
163
+ if (probe('python3', ['--version'])) return { cmd: 'python3', prefix: [] };
164
+ throw new Error(
165
+ 'Python 3 not found. Install Python 3 from https://www.python.org/downloads/ ' +
166
+ 'and make sure the "Add python.exe to PATH" option is checked during install.'
167
+ );
168
+ }
196
169
  if (probe('python3', ['--version'])) return { cmd: 'python3', prefix: [] };
170
+ if (probe('python', ['--version'])) return { cmd: 'python', prefix: [] };
197
171
  throw new Error(
198
- 'Python 3 not found. Install Python 3 from https://www.python.org/downloads/ ' +
199
- 'and make sure the "Add python.exe to PATH" option is checked during install.'
172
+ 'Python 3 not found. Install Python 3 and ensure `python3` is on your PATH.'
200
173
  );
201
174
  }
202
175
 
203
176
  /**
204
- * Windows counterpart to the curl|python3 pipe. Downloads the script to a
205
- * temp file and invokes Python directly no shell required.
177
+ * Downloads a setup script to a temp file and invokes Python directly — no
178
+ * shell, no `curl | python3` pipe. This is now the ONLY execution path on every
179
+ * OS (macOS/Linux/WSL and native Windows), which closes the silent-success pipe
180
+ * hole: a failed/partial download rejects from downloadToFile() BEFORE Python
181
+ * ever runs, so a broken download can no longer exit 0 as a fake success.
182
+ *
183
+ * `download failure` is surfaced as an Error carrying `downloadFailed: true` so
184
+ * the caller can report it to the failure ledger with the download sentinel.
206
185
  */
207
- async function runPythonScriptWindows(scriptPath, args, { capture }) {
186
+ async function runPythonScript(scriptPath, args, { capture }) {
208
187
  const url = `${SETUP_BASE_URL}/${scriptPath}`;
209
- const tmp = path.join(os.tmpdir(), `unbound-${Date.now()}-${Math.random().toString(36).slice(2)}.py`);
210
- await downloadToFile(url, tmp);
211
- const py = resolveWindowsPython();
212
- try {
213
- // `return await` so the resolved value (e.g. { mdmSkipped: true }) reaches
214
- // the caller; the finally below still runs before the function returns.
188
+ // The downloaded script is executed as the current user (root under
189
+ // `sudo unbound …`), so it must live in a per-run PRIVATE temp dir that a
190
+ // local attacker can't pre-create or symlink. withSecureTempFile creates that
191
+ // dir (mode 0700) and removes it — and the script file — when we're done.
192
+ return withSecureTempFile('.py', async (tmp) => {
193
+ try {
194
+ await downloadToFile(url, tmp);
195
+ } catch (err) {
196
+ // Mark download failures so callers can distinguish them from script-exit
197
+ // failures (different exit-code sentinel for the failure ledger).
198
+ err.downloadFailed = true;
199
+ throw err;
200
+ }
201
+ const py = resolvePython();
215
202
  return await new Promise((resolve, reject) => {
216
203
  const child = spawn(py.cmd, [...py.prefix, tmp, ...parsePosixArgs(args)], {
217
204
  stdio: capture ? ['pipe', 'pipe', 'pipe'] : 'inherit',
@@ -230,14 +217,15 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
230
217
  // Managed by MDM — drop any captured output and signal a skip.
231
218
  if (code === EXIT_MDM_PRESENT) return resolve({ mdmSkipped: true });
232
219
  const err = new Error(out.trim() || `Setup script failed with exit code ${code}`);
220
+ // Carry the numeric exit code so callers can report it to the failure
221
+ // ledger (reportSetupFailure) without re-parsing the message.
222
+ err.exitCode = code;
233
223
  if (capture) err.setupOutput = out.trim();
234
224
  reject(err);
235
225
  });
236
226
  child.on('error', reject);
237
227
  });
238
- } finally {
239
- try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
240
- }
228
+ });
241
229
  }
242
230
 
243
231
  /**
@@ -285,59 +273,46 @@ function noteBackfillUnsupported(label, scriptPath) {
285
273
  }
286
274
 
287
275
  /**
288
- * Runs a Python setup script from the setup repo with inherited stdio (live output).
276
+ * Runs a Python setup script from the setup repo with inherited stdio (live
277
+ * output). Download-then-run on every OS (see runPythonScript) so a failed
278
+ * download can't masquerade as success. Returns { mdmSkipped: true } when the
279
+ * script signals an MDM skip; throws on download or script failure.
289
280
  */
290
281
  async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
291
282
  const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
292
283
  console.log('');
293
- if (isWindowsNative()) {
294
- return runPythonScriptWindows(scriptPath, args, { capture: false });
295
- }
296
- try {
297
- execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
298
- } catch (err) {
299
- // Managed by MDM — the script already printed its one-line notice (stdio is
300
- // inherited here). Signal a skip so the caller can add the CLI notice.
301
- if (err.status === EXIT_MDM_PRESENT) return { mdmSkipped: true };
302
- throw new Error(`Setup script failed with exit code ${err.status || 1}. Check the output above for details.`);
303
- }
284
+ return runPythonScript(scriptPath, args, { capture: false });
304
285
  }
305
286
 
306
287
  /**
307
288
  * Runs a setup script with piped output (captured silently for spinner display).
308
- * Returns a promise that resolves on success, rejects with captured output on failure.
289
+ * Resolves on success ({ mdmSkipped: true } on an MDM skip), rejects with the
290
+ * captured output on failure. Download-then-run on every OS (see
291
+ * runPythonScript) so the previous `curl | python3` pipe hole — where a failed
292
+ * download exited 0 because dash has no pipefail — is closed.
309
293
  */
310
294
  function runScriptPiped(scriptPath, args) {
311
- if (isWindowsNative()) {
312
- return runPythonScriptWindows(scriptPath, args, { capture: true });
313
- }
314
- return new Promise((resolve, reject) => {
315
- const child = spawn(buildSetupCommand(scriptPath, args), {
316
- shell: true,
317
- stdio: ['pipe', 'pipe', 'pipe'],
318
- });
319
- child.stdin.on('error', () => {});
320
- child.stdin.end();
321
-
322
- let captured = '';
323
- child.stdout.on('data', (d) => { captured += d.toString(); });
324
- child.stderr.on('data', (d) => { captured += d.toString(); });
325
-
326
- child.on('close', (code) => {
327
- if (code === 0) {
328
- resolve();
329
- } else if (code === EXIT_MDM_PRESENT) {
330
- // Managed by MDM — drop the captured script output and signal a skip.
331
- resolve({ mdmSkipped: true });
332
- } else {
333
- const err = new Error(captured.trim() || `Setup failed with exit code ${code}`);
334
- err.setupOutput = captured.trim();
335
- reject(err);
336
- }
337
- });
295
+ return runPythonScript(scriptPath, args, { capture: true });
296
+ }
338
297
 
339
- child.on('error', reject);
340
- });
298
+ /**
299
+ * Single-tool wrapper around runSetupScript that best-effort reports a failure
300
+ * to the backend ledger before rethrowing. Used by the live (inherited-stdio)
301
+ * single-tool setup path so it gets the same failure observability as the batch
302
+ * paths. The report never changes the thrown error or the exit code.
303
+ */
304
+ async function runSetupScriptReported(script, stepName, apiKey, opts = {}) {
305
+ try {
306
+ return await runSetupScript(script, apiKey, opts);
307
+ } catch (err) {
308
+ await reportSetupFailure({
309
+ step: stepName,
310
+ exitCode: exitCodeForError(err),
311
+ apiKey,
312
+ backendUrl: opts.backendUrl,
313
+ });
314
+ throw err;
315
+ }
341
316
  }
342
317
 
343
318
  // Env vars Unbound writes during setup. The python `--clear` scripts strip
@@ -390,6 +365,85 @@ function clearUnboundEnvsEverywhere() {
390
365
  return cleared;
391
366
  }
392
367
 
368
+ // Layout written by the setup pkg's postinstall (setup repo @ 41ca7b2 —
369
+ // packaging/pkg/postinstall + pkg/ai.getunbound.discovery.plist). Hard-coded;
370
+ // never accept user input here. Frozen so a misbehaving test can't mutate the
371
+ // production paths for the rest of the process.
372
+ const BINARY_INSTALL_PATHS = Object.freeze({
373
+ optDir: '/opt/unbound',
374
+ daemonPlist: '/Library/LaunchDaemons/ai.getunbound.discovery.plist',
375
+ daemonLabel: 'ai.getunbound.discovery',
376
+ newsyslogConf: '/etc/newsyslog.d/ai.getunbound.conf',
377
+ logDir: '/var/log/unbound',
378
+ });
379
+
380
+ // Tears down the privileged binary install. Best-effort: a failure on any
381
+ // single path is logged via output.warn AND pushed onto the returned
382
+ // `failed` array, but never aborts the rest. Bootout the daemon FIRST so
383
+ // its supervisor doesn't relaunch mid-rm. Callers MUST gate this on root.
384
+ // Returns { removed, failed } — both labelled lists; nuke folds `failed`
385
+ // into its closing summary so a partial wipe doesn't print "success".
386
+ // Path overrides + `runLaunchctl` exist so tests stay hermetic — see
387
+ // test/setup-args.test.js. Safety net: any production-default path (or
388
+ // daemonLabel, when runLaunchctl is true) requires the caller to explicitly
389
+ // pass `_confirmProductionPaths:true` — dev machines often have a real
390
+ // install, and a forgotten override would wipe it. Production sets the
391
+ // flag; tests omit it and rely on the throw.
392
+ function removeBinaryInstall({
393
+ optDir = BINARY_INSTALL_PATHS.optDir,
394
+ daemonPlist = BINARY_INSTALL_PATHS.daemonPlist,
395
+ daemonLabel = BINARY_INSTALL_PATHS.daemonLabel,
396
+ newsyslogConf = BINARY_INSTALL_PATHS.newsyslogConf,
397
+ logDir = BINARY_INSTALL_PATHS.logDir,
398
+ runLaunchctl = process.platform === 'darwin',
399
+ _confirmProductionPaths = false,
400
+ } = {}) {
401
+ const usesProdDefaults =
402
+ optDir === BINARY_INSTALL_PATHS.optDir ||
403
+ daemonPlist === BINARY_INSTALL_PATHS.daemonPlist ||
404
+ newsyslogConf === BINARY_INSTALL_PATHS.newsyslogConf ||
405
+ logDir === BINARY_INSTALL_PATHS.logDir ||
406
+ (runLaunchctl && daemonLabel === BINARY_INSTALL_PATHS.daemonLabel);
407
+ if (usesProdDefaults && !_confirmProductionPaths) {
408
+ throw new Error('removeBinaryInstall: production-default path(s) used without _confirmProductionPaths:true — pass tmp overrides or set the flag explicitly');
409
+ }
410
+ const removed = [];
411
+ const failed = [];
412
+ if (runLaunchctl) {
413
+ // spawnSync does NOT throw on non-zero exit — it returns { status, error,
414
+ // stderr }. Treat "Could not find specified service" / "No such process"
415
+ // as the legitimate "daemon not loaded" case (first install, already
416
+ // booted out) and swallow it. Surface anything else so the operator knows
417
+ // the bootout-then-rm ordering may have lost the race.
418
+ const r = spawnSync('launchctl', ['bootout', `system/${daemonLabel}`], { stdio: 'pipe' });
419
+ const stderr = r.stderr ? r.stderr.toString().trim() : '';
420
+ if (r.error) {
421
+ output.warn(`nuke: launchctl bootout failed to spawn: ${r.error.message}`);
422
+ failed.push(`launchctl bootout ${daemonLabel}`);
423
+ } else if (r.status !== 0 && !/Could not find specified service|No such process/i.test(stderr)) {
424
+ output.warn(`nuke: launchctl bootout exited ${r.status}: ${stderr || '(no stderr)'}`);
425
+ failed.push(`launchctl bootout ${daemonLabel}`);
426
+ }
427
+ }
428
+ const targets = [
429
+ ['LaunchDaemon plist', daemonPlist],
430
+ ['newsyslog conf', newsyslogConf],
431
+ ['log dir', logDir],
432
+ ['install tree', optDir],
433
+ ];
434
+ for (const [label, p] of targets) {
435
+ try {
436
+ if (!fs.existsSync(p)) continue;
437
+ fs.rmSync(p, { recursive: true, force: true });
438
+ removed.push(`${label} (${p})`);
439
+ } catch (err) {
440
+ output.warn(`nuke: failed to remove ${label} (${p}): ${err.message}`);
441
+ failed.push(`${label} (${p})`);
442
+ }
443
+ }
444
+ return { removed, failed };
445
+ }
446
+
393
447
  /**
394
448
  * Returns true when the process has the privileges needed to touch system-level
395
449
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -428,8 +482,13 @@ function reportMdmSkips(labels) {
428
482
  * MDM-managed tool labels so callers can qualify their own success message.
429
483
  * When `summary` is set, a green success line is printed for the configured
430
484
  * tools before the MDM notice.
485
+ *
486
+ * `report` (optional) enables best-effort failure reporting to the backend
487
+ * ledger on a hard failure: { apiKey, backendUrl }. Reporting is skipped for
488
+ * --clear (clearing isn't an onboarding step) and never affects the exit code
489
+ * or the error surfaced to the user.
431
490
  */
432
- async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
491
+ async function runBatch(tools, runFn, { clear = false, summary = null, report = null } = {}) {
433
492
  const action = clear ? 'Clearing' : 'Setting up';
434
493
  const mdmSkipped = [];
435
494
  for (const tool of tools) {
@@ -449,6 +508,15 @@ async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
449
508
  s.fail(`Failed: ${tool.label}`);
450
509
  if (err.setupOutput) console.error('\n' + err.setupOutput);
451
510
  process.exitCode = 1;
511
+ // Best-effort failure report. await is safe — reportSetupFailure never rejects.
512
+ if (report && !clear) {
513
+ await reportSetupFailure({
514
+ step: tool.name || tool.label,
515
+ exitCode: exitCodeForError(err),
516
+ apiKey: report.apiKey,
517
+ backendUrl: report.backendUrl,
518
+ });
519
+ }
452
520
  // Still surface any tools skipped before this failure so the notice isn't lost.
453
521
  reportMdmSkips(mdmSkipped);
454
522
  return { ok: false, skipped: mdmSkipped };
@@ -581,7 +649,10 @@ setup for that tool is skipped automatically — the managed configuration alrea
581
649
  enforces Unbound for every user on the device. To change it, an administrator
582
650
  must update the MDM configuration.
583
651
  `)
584
- .action(async (tools, opts) => {
652
+ .action(telemetry.wrapAction('setup', async (tools, opts) => {
653
+ // Register secret values so beforeSend redacts them from any captured event.
654
+ telemetry.rememberSecret(opts.apiKey);
655
+ telemetry.rememberSecret(opts.adminApiKey);
585
656
  try {
586
657
  // Persist URLs first, login, then setup. setUrls is atomic; a malformed
587
658
  // URL throws before any disk write.
@@ -680,7 +751,7 @@ must update the MDM configuration.
680
751
  });
681
752
  return runScriptPiped(tool.script, toolArgs);
682
753
  },
683
- { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' }
754
+ { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey: adminApiKey, backendUrl } }
684
755
  );
685
756
  if (!ok) return;
686
757
  return;
@@ -735,7 +806,7 @@ must update the MDM configuration.
735
806
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
736
807
  });
737
808
  return runScriptPiped(tool.script, toolArgs);
738
- }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
809
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
739
810
  return;
740
811
  }
741
812
 
@@ -801,7 +872,10 @@ must update the MDM configuration.
801
872
  const { script, label } = SETUP_TOOL_MAP[toolName];
802
873
  const backfill = opts.backfill && scriptSupportsBackfill(script);
803
874
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
804
- const r = await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
875
+ // Clear paths aren't onboarding steps, so don't report their failures.
876
+ const r = opts.clear
877
+ ? await runSetupScript(script, apiKey, { clear: true, backfill, ...urlOpts })
878
+ : await runSetupScriptReported(script, toolName, apiKey, { backfill, ...urlOpts });
805
879
  if (r && r.mdmSkipped) reportMdmSkips([label]);
806
880
  } else if (MODE_TOOLS[toolName]) {
807
881
  const mode = MODE_TOOLS[toolName];
@@ -819,7 +893,7 @@ must update the MDM configuration.
819
893
  const { script, label } = SETUP_TOOL_MAP[resolved];
820
894
  const backfill = opts.backfill && scriptSupportsBackfill(script);
821
895
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
822
- const r = await runSetupScript(script, apiKey, { ...urlOpts, backfill });
896
+ const r = await runSetupScriptReported(script, resolved, apiKey, { ...urlOpts, backfill });
823
897
  if (r && r.mdmSkipped) reportMdmSkips([label]);
824
898
  }
825
899
  } else if (INSTRUCTION_TOOLS[toolName]) {
@@ -870,7 +944,7 @@ must update the MDM configuration.
870
944
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
871
945
  });
872
946
  return runScriptPiped(tool.script, toolArgs);
873
- }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
947
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
874
948
  if (!ok) return;
875
949
  }
876
950
 
@@ -883,10 +957,11 @@ must update the MDM configuration.
883
957
 
884
958
  } catch (err) {
885
959
  if (err.message === 'Selection cancelled') return;
960
+ telemetry.captureError(err, { tags: { flow: 'setup' } });
886
961
  if (!err.displayed) output.error(err.message);
887
962
  process.exitCode = 1;
888
963
  }
889
- });
964
+ }));
890
965
 
891
966
  // --- Full uninstall ---
892
967
 
@@ -956,9 +1031,21 @@ Examples:
956
1031
 
957
1032
  // MDM clears need root and touch all users — run them only when we have it.
958
1033
  let mdmFailed = [];
1034
+ let binaryFailed = [];
959
1035
  if (includeMdm) {
960
1036
  const mdmTools = Object.keys(MDM_TOOLS).map(name => ({ name, ...MDM_TOOLS[name] }));
961
1037
  mdmFailed = await clearToolsBestEffort('mdm', mdmTools, { mdm: true, backendUrl, gatewayUrl });
1038
+
1039
+ // Root-only by construction (gated on includeMdm). No-op when no binary
1040
+ // install is present (python-only setups) and on Windows.
1041
+ // `_confirmProductionPaths` is the explicit "this is the real call site"
1042
+ // ack — the function refuses production defaults without it as a guard
1043
+ // against tests forgetting to override paths.
1044
+ const { removed: binaryRemoved, failed: bf } = removeBinaryInstall({ _confirmProductionPaths: true });
1045
+ binaryFailed = bf;
1046
+ if (binaryRemoved.length) {
1047
+ output.info(`Removed binary install: ${binaryRemoved.join('; ')}.`);
1048
+ }
962
1049
  } else {
963
1050
  output.info('Skipped MDM (system-level) config — that needs root. Re-run with sudo to remove it too.');
964
1051
  }
@@ -975,12 +1062,12 @@ Examples:
975
1062
  output.success('Stored credentials and settings removed.');
976
1063
 
977
1064
  console.log('');
978
- const failed = [...userFailed, ...mdmFailed];
1065
+ const failed = [...userFailed, ...mdmFailed, ...binaryFailed];
979
1066
  const scope = includeMdm ? 'on this device' : 'for your user';
980
1067
  if (failed.length === 0) {
981
1068
  output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
982
1069
  } else {
983
- output.warn(`Done ${scope}, but ${failed.length} tool clear(s) reported issues: ${failed.join(', ')}. Credentials were still removed.`);
1070
+ output.warn(`Done ${scope}, but ${failed.length} step(s) reported issues: ${failed.join(', ')}. Credentials were still removed.`);
984
1071
  }
985
1072
  } catch (err) {
986
1073
  output.error(err.message);
@@ -1005,14 +1092,13 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
1005
1092
  // actually supports it (Claude Code hooks, Codex hooks, Copilot hooks).
1006
1093
  // Cursor would print "not supported"; passing the flag to gateway-mode
1007
1094
  // scripts would error out — `scriptSupportsBackfill` checks for both.
1008
- const result = await runBatch(resolvedTools, (tool) => {
1095
+ return await runBatch(resolvedTools, (tool) => {
1009
1096
  const args = buildScriptArgs(apiKey, {
1010
1097
  backendUrl, frontendUrl, gatewayUrl,
1011
1098
  backfill: backfill && scriptSupportsBackfill(tool.script),
1012
1099
  });
1013
1100
  return runScriptPiped(tool.script, args);
1014
- });
1015
- return result;
1101
+ }, { report: { apiKey, backendUrl } });
1016
1102
  }
1017
1103
 
1018
1104
  /**
@@ -1027,14 +1113,13 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1027
1113
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
1028
1114
  }
1029
1115
  }
1030
- const result = await runBatch(resolvedTools, (tool) => {
1116
+ return await runBatch(resolvedTools, (tool) => {
1031
1117
  const args = buildScriptArgs(adminApiKey, {
1032
1118
  backendUrl, frontendUrl, gatewayUrl, mdm: true,
1033
1119
  backfill: backfill && scriptSupportsBackfill(tool.script),
1034
1120
  });
1035
1121
  return runScriptPiped(tool.script, args);
1036
- });
1037
- return result;
1122
+ }, { report: { apiKey: adminApiKey, backendUrl } });
1038
1123
  }
1039
1124
 
1040
1125
  module.exports = {
@@ -1049,4 +1134,6 @@ module.exports = {
1049
1134
  resolveSetupAllTools,
1050
1135
  clearUnboundEnvsEverywhere,
1051
1136
  NUKE_ENV_VARS,
1137
+ removeBinaryInstall,
1138
+ BINARY_INSTALL_PATHS,
1052
1139
  };
package/src/index.js CHANGED
@@ -14,6 +14,16 @@ const config = require('./config');
14
14
  const output = require('./output');
15
15
  const { version } = require('../package.json');
16
16
  const { checkForUpdates } = require('./update-check');
17
+ const telemetry = require('./telemetry');
18
+ const { getRunId } = require('./run-id');
19
+
20
+ // Initialize opt-out Sentry as early as possible so a crash anywhere in command
21
+ // parsing is captured. Fully disabled (no init, no network) unless
22
+ // UNBOUND_CLI_SENTRY_DSN is set and UNBOUND_TELEMETRY is not opted out — see
23
+ // telemetry.js. The global flow tag is the neutral 'cli' default; wrapAction
24
+ // overrides it per command (setup/discover/onboard) so command events are
25
+ // tagged accurately. Tagged with the shared run id for correlation. Never throws.
26
+ telemetry.init({ flow: 'cli', runId: getRunId() });
17
27
 
18
28
  checkForUpdates();
19
29
 
@@ -367,4 +377,25 @@ configCmd
367
377
  ]);
368
378
  });
369
379
 
370
- program.parse(process.argv);
380
+ // Every command inits telemetry at startup, but only setup/onboard/discover
381
+ // flush via wrapAction — all other commands left Sentry's background timers
382
+ // holding the event loop open ~1min when a DSN is configured. parseAsync runs
383
+ // the (already-async) command actions to completion, then a single finally
384
+ // flushes once for ALL current and future commands. flush() is a fast no-op
385
+ // when telemetry is disabled (no DSN), so the common path adds zero latency,
386
+ // and a double-flush with wrapAction is safe (flush sets initialized=false).
387
+ // Command actions set process.exitCode themselves; this wrapper preserves it.
388
+ (async () => {
389
+ try {
390
+ await program.parseAsync(process.argv);
391
+ } catch (err) {
392
+ // Defensive: every command action handles its own errors (prints + sets
393
+ // process.exitCode), so this only fires if one throws past its own
394
+ // try/catch. Surface a clean message and a non-zero exit instead of an
395
+ // UnhandledPromiseRejection crash, preserving any exit code already set.
396
+ output.error(err.message);
397
+ process.exitCode = process.exitCode || 1;
398
+ } finally {
399
+ await telemetry.flush();
400
+ }
401
+ })();
package/src/run-id.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * One run id per onboard / setup / discover invocation.
3
+ *
4
+ * Generated once (UUID v4) and reused for the whole flow so a broken onboard —
5
+ * setup step, discovery, and any captured Sentry event — is correlatable under
6
+ * a single id. Discovery's own backend DeviceScan run id is generated inside
7
+ * the discovery script (a separate repo), not the CLI, so there is no second
8
+ * client-side scheme to collide with; this is purely the CLI-side correlation
9
+ * id used for Sentry tags and failure-report logging.
10
+ */
11
+
12
+ const { randomUUID } = require('node:crypto');
13
+
14
+ let current = null;
15
+
16
+ /** Returns the current run id, generating one on first use. */
17
+ function getRunId() {
18
+ if (!current) current = randomUUID();
19
+ return current;
20
+ }
21
+
22
+ /** Test-only reset so each test starts from a clean slate. */
23
+ function _reset() {
24
+ current = null;
25
+ }
26
+
27
+ module.exports = { getRunId, _reset };