unbound-cli 1.6.4 → 1.7.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.
@@ -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.
159
- */
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.
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`.
186
152
  */
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
@@ -507,8 +482,13 @@ function reportMdmSkips(labels) {
507
482
  * MDM-managed tool labels so callers can qualify their own success message.
508
483
  * When `summary` is set, a green success line is printed for the configured
509
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.
510
490
  */
511
- async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
491
+ async function runBatch(tools, runFn, { clear = false, summary = null, report = null } = {}) {
512
492
  const action = clear ? 'Clearing' : 'Setting up';
513
493
  const mdmSkipped = [];
514
494
  for (const tool of tools) {
@@ -528,6 +508,15 @@ async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
528
508
  s.fail(`Failed: ${tool.label}`);
529
509
  if (err.setupOutput) console.error('\n' + err.setupOutput);
530
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
+ }
531
520
  // Still surface any tools skipped before this failure so the notice isn't lost.
532
521
  reportMdmSkips(mdmSkipped);
533
522
  return { ok: false, skipped: mdmSkipped };
@@ -660,7 +649,10 @@ setup for that tool is skipped automatically — the managed configuration alrea
660
649
  enforces Unbound for every user on the device. To change it, an administrator
661
650
  must update the MDM configuration.
662
651
  `)
663
- .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);
664
656
  try {
665
657
  // Persist URLs first, login, then setup. setUrls is atomic; a malformed
666
658
  // URL throws before any disk write.
@@ -759,7 +751,7 @@ must update the MDM configuration.
759
751
  });
760
752
  return runScriptPiped(tool.script, toolArgs);
761
753
  },
762
- { 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 } }
763
755
  );
764
756
  if (!ok) return;
765
757
  return;
@@ -814,7 +806,7 @@ must update the MDM configuration.
814
806
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
815
807
  });
816
808
  return runScriptPiped(tool.script, toolArgs);
817
- }, { 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 } });
818
810
  return;
819
811
  }
820
812
 
@@ -880,7 +872,10 @@ must update the MDM configuration.
880
872
  const { script, label } = SETUP_TOOL_MAP[toolName];
881
873
  const backfill = opts.backfill && scriptSupportsBackfill(script);
882
874
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
883
- 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 });
884
879
  if (r && r.mdmSkipped) reportMdmSkips([label]);
885
880
  } else if (MODE_TOOLS[toolName]) {
886
881
  const mode = MODE_TOOLS[toolName];
@@ -898,7 +893,7 @@ must update the MDM configuration.
898
893
  const { script, label } = SETUP_TOOL_MAP[resolved];
899
894
  const backfill = opts.backfill && scriptSupportsBackfill(script);
900
895
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
901
- const r = await runSetupScript(script, apiKey, { ...urlOpts, backfill });
896
+ const r = await runSetupScriptReported(script, resolved, apiKey, { ...urlOpts, backfill });
902
897
  if (r && r.mdmSkipped) reportMdmSkips([label]);
903
898
  }
904
899
  } else if (INSTRUCTION_TOOLS[toolName]) {
@@ -949,7 +944,7 @@ must update the MDM configuration.
949
944
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
950
945
  });
951
946
  return runScriptPiped(tool.script, toolArgs);
952
- }, { 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 } });
953
948
  if (!ok) return;
954
949
  }
955
950
 
@@ -962,10 +957,11 @@ must update the MDM configuration.
962
957
 
963
958
  } catch (err) {
964
959
  if (err.message === 'Selection cancelled') return;
960
+ telemetry.captureError(err, { tags: { flow: 'setup' } });
965
961
  if (!err.displayed) output.error(err.message);
966
962
  process.exitCode = 1;
967
963
  }
968
- });
964
+ }));
969
965
 
970
966
  // --- Full uninstall ---
971
967
 
@@ -1096,14 +1092,13 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
1096
1092
  // actually supports it (Claude Code hooks, Codex hooks, Copilot hooks).
1097
1093
  // Cursor would print "not supported"; passing the flag to gateway-mode
1098
1094
  // scripts would error out — `scriptSupportsBackfill` checks for both.
1099
- const result = await runBatch(resolvedTools, (tool) => {
1095
+ return await runBatch(resolvedTools, (tool) => {
1100
1096
  const args = buildScriptArgs(apiKey, {
1101
1097
  backendUrl, frontendUrl, gatewayUrl,
1102
1098
  backfill: backfill && scriptSupportsBackfill(tool.script),
1103
1099
  });
1104
1100
  return runScriptPiped(tool.script, args);
1105
- });
1106
- return result;
1101
+ }, { report: { apiKey, backendUrl } });
1107
1102
  }
1108
1103
 
1109
1104
  /**
@@ -1118,14 +1113,13 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1118
1113
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
1119
1114
  }
1120
1115
  }
1121
- const result = await runBatch(resolvedTools, (tool) => {
1116
+ return await runBatch(resolvedTools, (tool) => {
1122
1117
  const args = buildScriptArgs(adminApiKey, {
1123
1118
  backendUrl, frontendUrl, gatewayUrl, mdm: true,
1124
1119
  backfill: backfill && scriptSupportsBackfill(tool.script),
1125
1120
  });
1126
1121
  return runScriptPiped(tool.script, args);
1127
- });
1128
- return result;
1122
+ }, { report: { apiKey: adminApiKey, backendUrl } });
1129
1123
  }
1130
1124
 
1131
1125
  module.exports = {
@@ -2,7 +2,7 @@ const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
4
  const { getDeviceSerial } = require('../device-serial');
5
- const { detectTools } = require('../toolHealth');
5
+ const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
6
6
 
7
7
  function roleFromPrivileges(p) {
8
8
  if (!p) return null;
@@ -76,8 +76,17 @@ Examples:
76
76
  pairs.push(['API status', connectivity]);
77
77
 
78
78
  // Locally detected AI tools wired through Unbound, with their mode.
79
- const connected = detectTools({ gatewayUrl: config.getGatewayUrl(), apiKey: config.getApiKey() })
80
- .filter((t) => t.status !== 'not-installed');
79
+ // WEB-4949: per-tool API keys are validated against the gateway in
80
+ // parallel (fail-open on network errors) see toolHealth.validateToolKeys.
81
+ // The spinner is essential — N keys × 3s timeout on a slow link would
82
+ // otherwise look like the command had frozen.
83
+ const baseUrl = config.getBaseUrl();
84
+ const detected = detectTools({ gatewayUrl: config.getGatewayUrl() });
85
+ const keySpin = output.spinner('Validating tool API keys...');
86
+ await validateToolKeys(detected, (k) => api.validateApiKey(k, { baseUrl }));
87
+ keySpin.stop();
88
+ const connected = detected.filter((t) => t.status !== 'not-installed');
89
+ const skippedCount = countValidationSkipped(connected);
81
90
 
82
91
  if (opts.json) {
83
92
  const cfg = loggedIn ? config.readConfig() : {};
@@ -92,6 +101,7 @@ Examples:
92
101
  gateway_url: config.getGatewayUrl(),
93
102
  api_status: connectivity,
94
103
  connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
104
+ key_validations_skipped: skippedCount,
95
105
  });
96
106
  return;
97
107
  }
@@ -113,6 +123,11 @@ Examples:
113
123
  console.log(` ${mark} ${t.label}${mode}${note}`);
114
124
  }
115
125
  }
126
+ // WEB-4949: tell the operator when fail-open masked a real check.
127
+ // Without this, an offline run looks identical to a fully-validated one.
128
+ if (skippedCount > 0) {
129
+ console.log(` ${C.dim(`Note: ${skippedCount} API key validation${skippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
130
+ }
116
131
  console.log('');
117
132
  } catch (err) {
118
133
  output.error(err.message);
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 };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Best-effort reporting of onboarding/setup step failures to the backend's
3
+ * setup-failure ledger (POST /api/v1/setup/failed/).
4
+ *
5
+ * This is the client half of "no onboarding step fails silently": when a setup
6
+ * step fails (script exits non-zero, throws, or its download fails) the CLI
7
+ * records the failure against the device so it shows up server-side instead of
8
+ * vanishing.
9
+ *
10
+ * CONTRACT (do not change here — backend owns it):
11
+ * POST {backendUrl}/api/v1/setup/failed/
12
+ * body: { serial_number: string (<=255, required),
13
+ * step: string (<=64, required),
14
+ * exit_code: integer }
15
+ * auth: same as /setup/complete/ — gateway API key in Authorization header,
16
+ * User-Agent "UnboundCLI/<version>". install_mode/managed are forced
17
+ * server-side, so we never send them.
18
+ *
19
+ * BEST-EFFORT, ALWAYS: a telemetry POST failure (offline, 429, 4xx, timeout)
20
+ * must NEVER mask or replace the real error shown to the user, and must NOT
21
+ * change the CLI's exit code. Every path here swallows its own errors.
22
+ */
23
+
24
+ const os = require('os');
25
+ const api = require('./api');
26
+ const config = require('./config');
27
+
28
+ // The failure report is best-effort telemetry that runs while the user is
29
+ // already waiting on a real setup failure. Bound it tightly (well under the
30
+ // default 30s API timeout) so a hung/slow backend can't delay surfacing the
31
+ // user's error. On timeout the POST rejects and the catch below swallows it.
32
+ const REPORT_TIMEOUT_MS = 3000;
33
+
34
+ // JS-thrown error (no process exit code available).
35
+ const EXIT_THROWN = 1;
36
+ // Download/transport failure before any process ran.
37
+ const EXIT_DOWNLOAD_FAILED = -1;
38
+
39
+ /**
40
+ * Best-effort device identifier for the failure ledger. The Node CLI can't read
41
+ * the hardware serial reliably, so we use os.hostname() — the backend only uses
42
+ * serial_number as an org-scoped upsert key, not as a true hardware serial.
43
+ * Truncated to the backend's 255-char limit; falls back to "unknown-device".
44
+ */
45
+ function deviceIdentifier() {
46
+ let name;
47
+ try { name = os.hostname(); } catch { name = null; }
48
+ if (!name || typeof name !== 'string' || !name.trim()) return 'unknown-device';
49
+ return name.trim().slice(0, 255);
50
+ }
51
+
52
+ // Normalize a step name to the backend's 64-char limit so an over-long stage
53
+ // label can't cause a 400 that we'd then (silently) drop.
54
+ function normalizeStep(step) {
55
+ const s = (step == null ? '' : String(step)).trim() || 'unknown';
56
+ return s.slice(0, 64);
57
+ }
58
+
59
+ // Coerce to an integer exit code; default to the thrown-error sentinel.
60
+ function normalizeExitCode(code) {
61
+ const n = Number(code);
62
+ return Number.isInteger(n) ? n : EXIT_THROWN;
63
+ }
64
+
65
+ // Maps a caught error to the exit code we report to the ledger: a download
66
+ // failure uses the download sentinel, a script exit uses its real code, and
67
+ // anything else falls back to the thrown sentinel.
68
+ function exitCodeForError(err) {
69
+ if (err && err.downloadFailed) return EXIT_DOWNLOAD_FAILED;
70
+ if (err && Number.isInteger(err.exitCode)) return err.exitCode;
71
+ return EXIT_THROWN;
72
+ }
73
+
74
+ /**
75
+ * Reports a single failed setup step. Resolves to true if the POST succeeded,
76
+ * false otherwise — but NEVER rejects, so callers can `await` it inline in an
77
+ * error path without risk of masking the original failure.
78
+ *
79
+ * @param {object} args
80
+ * @param {string} args.step - the tool/stage that failed (<=64 chars).
81
+ * @param {number} args.exitCode - the process exit code (or a sentinel).
82
+ * @param {string} [args.apiKey] - gateway API key (defaults to stored key).
83
+ * @param {string} [args.backendUrl] - explicit backend URL override.
84
+ */
85
+ async function reportSetupFailure({ step, exitCode, apiKey, backendUrl } = {}) {
86
+ try {
87
+ const key = apiKey || config.getApiKey();
88
+ // No key → nothing to authenticate with; the ledger upserts by org, so a
89
+ // keyless report can't be attributed. Skip quietly.
90
+ if (!key) return false;
91
+
92
+ await api.post('/api/v1/setup/failed/', {
93
+ apiKey: key,
94
+ baseUrl: backendUrl,
95
+ timeoutMs: REPORT_TIMEOUT_MS,
96
+ body: {
97
+ serial_number: deviceIdentifier(),
98
+ step: normalizeStep(step),
99
+ exit_code: normalizeExitCode(exitCode),
100
+ },
101
+ });
102
+ return true;
103
+ } catch {
104
+ // Offline / 429 / 4xx / timeout — all swallowed. Telemetry must never
105
+ // surface over the real error or change the exit code.
106
+ return false;
107
+ }
108
+ }
109
+
110
+ module.exports = {
111
+ reportSetupFailure,
112
+ deviceIdentifier,
113
+ exitCodeForError,
114
+ EXIT_THROWN,
115
+ EXIT_DOWNLOAD_FAILED,
116
+ // Exported for tests:
117
+ _normalizeStep: normalizeStep,
118
+ _normalizeExitCode: normalizeExitCode,
119
+ };