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.
- package/README.md +25 -0
- package/package.json +2 -1
- package/scripts/verify-nuke-ubuntu.sh +92 -0
- 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 +204 -117
- 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-args.test.js +178 -1
- 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
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|