unbound-cli 1.6.4 → 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.
- package/README.md +25 -0
- package/package.json +2 -1
- package/src/api.js +9 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +109 -115
- package/src/index.js +32 -1
- package/src/run-id.js +27 -0
- package/src/setup-report.js +119 -0
- package/src/telemetry.js +201 -0
- package/src/utils.js +86 -10
- package/test/auth-server-leak.test.js +201 -0
- package/test/cli-flush.test.js +101 -0
- package/test/download-pipe-hole.test.js +221 -0
- package/test/onboard-failure-exit.test.js +125 -0
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
package/src/commands/setup.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
const {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
158
|
-
*
|
|
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
|
|
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 (
|
|
195
|
-
|
|
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
|
|
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
|
-
*
|
|
205
|
-
*
|
|
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
|
|
186
|
+
async function runPythonScript(scriptPath, args, { capture }) {
|
|
208
187
|
const url = `${SETUP_BASE_URL}/${scriptPath}`;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
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
|
-
|
|
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
|
+
};
|