unbound-cli 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -11,6 +11,13 @@ const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
11
11
  // (e.g. Linux). Treated as a skipped scan, not a failure.
12
12
  const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
13
13
 
14
+ // Self-imposed discovery run timeout (seconds), forwarded through install.sh to
15
+ // the discovery process so `unbound onboard` / `unbound discover` can't hang
16
+ // indefinitely. Discovery enforces this itself — on expiry it releases its lock,
17
+ // reports the run as failed, and exits non-zero. Kept in sync with
18
+ // setup/mdm/onboard.py's DISCOVERY_TIMEOUT_SECONDS.
19
+ const DISCOVERY_TIMEOUT_SECONDS = 1800;
20
+
14
21
  // Classifies a discovery subprocess exit code:
15
22
  // 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
16
23
  function classifyDiscoveryExit(code) {
@@ -146,7 +153,16 @@ async function runDiscoveryScan({ apiKey, domain }) {
146
153
  console.log('');
147
154
  }
148
155
 
156
+ // Don't pass --timeout: install.sh is fetched from coding-discovery-tool/main,
157
+ // and an older discovery there would reject the unknown flag (argparse exits
158
+ // non-zero) and fail the scan. Discovery self-bounds via its own default (kept
159
+ // equal to DISCOVERY_TIMEOUT_SECONDS), so this is correct regardless of merge
160
+ // order. Surface the bound so the wait isn't a mystery; on expiry the discovery
161
+ // process prints its own line (stdio is inherited) and exits non-zero.
149
162
  const args = `--api-key ${shellEscape(apiKey)} --domain ${shellEscape(domain)}`;
163
+ if (!isWindowsNative()) {
164
+ output.info(`Discovery stops on its own after ${Math.round(DISCOVERY_TIMEOUT_SECONDS / 60)} minutes if it can't finish.`);
165
+ }
150
166
  await runDiscoveryScript('install.sh', args);
151
167
  }
152
168
 
@@ -101,7 +101,7 @@ Examples:
101
101
 
102
102
  console.log('');
103
103
  output.info('Step 1/2: Installing tool bundle');
104
- const ok = await runSetupAllBundle(apiKey, {
104
+ const { ok, skipped } = await runSetupAllBundle(apiKey, {
105
105
  backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
106
106
  });
107
107
  if (!ok) return;
@@ -127,7 +127,9 @@ Examples:
127
127
  }
128
128
 
129
129
  console.log('');
130
- output.success('Onboarding complete');
130
+ output.success(skipped && skipped.length
131
+ ? 'Onboarding complete — tools managed by MDM were skipped (see above)'
132
+ : 'Onboarding complete');
131
133
  } catch (err) {
132
134
  if (!err.displayed) output.error(err.message);
133
135
  if (discoverySucceeded && opts.setCron) {
@@ -237,7 +239,7 @@ Examples:
237
239
 
238
240
  console.log('');
239
241
  output.info('Step 1/2: Installing MDM tool bundle');
240
- const ok = await runMdmSetupAllBundle(adminApiKey, {
242
+ const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
241
243
  backendUrl, gatewayUrl, backfill: !!opts.backfill,
242
244
  });
243
245
  if (!ok) return;
@@ -249,7 +251,9 @@ Examples:
249
251
  await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
250
252
 
251
253
  console.log('');
252
- output.success('MDM onboarding complete');
254
+ output.success(skipped && skipped.length
255
+ ? 'MDM onboarding complete — tools managed by MDM were skipped (see above)'
256
+ : 'MDM onboarding complete');
253
257
  } catch (err) {
254
258
  if (!err.displayed) output.error(err.message);
255
259
  if (setupSucceeded) {
@@ -11,6 +11,14 @@ const { confirm } = require('../utils');
11
11
 
12
12
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
13
13
 
14
+ // A setup script exits with this code when it detects an existing MDM (managed)
15
+ // install for that tool and skips itself. It is a deliberate, non-fatal signal —
16
+ // not a failure — so the CLI suppresses the script's own output and reports the
17
+ // skip cleanly instead. Must stay in sync with the setup repo's setup.py scripts.
18
+ // Only emitted by setup runs: the scripts run the MDM check AFTER the --clear
19
+ // branch, so clear/nuke operations never produce this code.
20
+ const EXIT_MDM_PRESENT = 3;
21
+
14
22
  // WSL reports as Linux via uname; only native Windows (cmd.exe / PowerShell)
15
23
  // takes the Windows code path. WSL keeps using the Linux curl|python3 pipe.
16
24
  function isWindowsNative() {
@@ -200,7 +208,9 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
200
208
  await downloadToFile(url, tmp);
201
209
  const py = resolveWindowsPython();
202
210
  try {
203
- await new Promise((resolve, reject) => {
211
+ // `return await` so the resolved value (e.g. { mdmSkipped: true }) reaches
212
+ // the caller; the finally below still runs before the function returns.
213
+ return await new Promise((resolve, reject) => {
204
214
  const child = spawn(py.cmd, [...py.prefix, tmp, ...parsePosixArgs(args)], {
205
215
  stdio: capture ? ['pipe', 'pipe', 'pipe'] : 'inherit',
206
216
  shell: false,
@@ -215,6 +225,8 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
215
225
  }
216
226
  child.on('close', (code) => {
217
227
  if (code === 0) return resolve();
228
+ // Managed by MDM — drop any captured output and signal a skip.
229
+ if (code === EXIT_MDM_PRESENT) return resolve({ mdmSkipped: true });
218
230
  const err = new Error(out.trim() || `Setup script failed with exit code ${code}`);
219
231
  if (capture) err.setupOutput = out.trim();
220
232
  reject(err);
@@ -276,12 +288,14 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
276
288
  const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
277
289
  console.log('');
278
290
  if (isWindowsNative()) {
279
- await runPythonScriptWindows(scriptPath, args, { capture: false });
280
- return;
291
+ return runPythonScriptWindows(scriptPath, args, { capture: false });
281
292
  }
282
293
  try {
283
294
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
284
295
  } catch (err) {
296
+ // Managed by MDM — the script already printed its one-line notice (stdio is
297
+ // inherited here). Signal a skip so the caller can add the CLI notice.
298
+ if (err.status === EXIT_MDM_PRESENT) return { mdmSkipped: true };
285
299
  throw new Error(`Setup script failed with exit code ${err.status || 1}. Check the output above for details.`);
286
300
  }
287
301
  }
@@ -309,6 +323,9 @@ function runScriptPiped(scriptPath, args) {
309
323
  child.on('close', (code) => {
310
324
  if (code === 0) {
311
325
  resolve();
326
+ } else if (code === EXIT_MDM_PRESENT) {
327
+ // Managed by MDM — drop the captured script output and signal a skip.
328
+ resolve({ mdmSkipped: true });
312
329
  } else {
313
330
  const err = new Error(captured.trim() || `Setup failed with exit code ${code}`);
314
331
  err.setupOutput = captured.trim();
@@ -350,24 +367,61 @@ function hasRootPrivileges() {
350
367
  }
351
368
 
352
369
  /**
353
- * Runs a batch of tools sequentially with spinners.
354
- * Stops on first failure. Returns true if all succeeded.
370
+ * Prints a clear, red, end-of-run notice for tools that were skipped because an
371
+ * MDM (organization-managed) setup is already present. Kept separate so callers
372
+ * can surface it at the very end of their flow.
373
+ */
374
+ function reportMdmSkips(labels) {
375
+ if (!labels || labels.length === 0) return;
376
+ console.error('');
377
+ console.error(output.colors.bold(output.colors.red(
378
+ `✗ Skipped — managed by your organization (MDM): ${labels.join(', ')}`)));
379
+ console.error(output.colors.red(" User-level setup can't override MDM. Contact your IT admin to change it."));
380
+ }
381
+
382
+ /**
383
+ * Runs a batch of tools sequentially with spinners. Stops on the first hard
384
+ * failure. A tool that reports an MDM skip is NOT a failure — it's collected and
385
+ * surfaced (in red) at the end, and the batch continues. Returns
386
+ * { ok, skipped } — ok is false only on a hard failure; skipped is the list of
387
+ * MDM-managed tool labels so callers can qualify their own success message.
388
+ * When `summary` is set, a green success line is printed for the configured
389
+ * tools before the MDM notice.
355
390
  */
356
- async function runBatch(tools, runFn, { clear = false } = {}) {
391
+ async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
357
392
  const action = clear ? 'Clearing' : 'Setting up';
393
+ const mdmSkipped = [];
358
394
  for (const tool of tools) {
359
395
  const s = output.spinner(`${action} ${tool.label}...`);
360
396
  try {
361
- await runFn(tool);
362
- s.succeed(tool.label);
397
+ const result = await runFn(tool);
398
+ if (result && result.mdmSkipped) {
399
+ // Stop the spinner and leave an inline marker so the tool doesn't just
400
+ // vanish mid-batch; the actionable red summary still prints at the end.
401
+ s.stop();
402
+ console.error(output.colors.dim(` Skipped ${tool.label} — managed by MDM`));
403
+ mdmSkipped.push(tool.label);
404
+ } else {
405
+ s.succeed(tool.label);
406
+ }
363
407
  } catch (err) {
364
408
  s.fail(`Failed: ${tool.label}`);
365
409
  if (err.setupOutput) console.error('\n' + err.setupOutput);
366
410
  process.exitCode = 1;
367
- return false;
411
+ // Still surface any tools skipped before this failure so the notice isn't lost.
412
+ reportMdmSkips(mdmSkipped);
413
+ return { ok: false, skipped: mdmSkipped };
368
414
  }
369
415
  }
370
- return true;
416
+ const configured = tools.length - mdmSkipped.length;
417
+ if (summary && configured > 0) {
418
+ console.log('');
419
+ // Some tools may have been MDM-managed, so report the actual count and the
420
+ // matching verb. (clear never produces skips, but keep the verb consistent.)
421
+ output.success(mdmSkipped.length ? `${configured} of ${tools.length} tools ${clear ? 'cleared' : 'configured'}` : summary);
422
+ }
423
+ reportMdmSkips(mdmSkipped);
424
+ return { ok: true, skipped: mdmSkipped };
371
425
  }
372
426
 
373
427
  /**
@@ -468,6 +522,11 @@ Examples:
468
522
  When setting up, if you are not logged in and --api-key is not provided, the
469
523
  browser opens automatically to authenticate first. Clearing (--clear) never
470
524
  requires authentication.
525
+
526
+ If an MDM (organization-managed) setup is already present for a tool, user-level
527
+ setup for that tool is skipped automatically — the managed configuration already
528
+ enforces Unbound for every user on the device. To change it, an administrator
529
+ must update the MDM configuration.
471
530
  `)
472
531
  .action(async (tools, opts) => {
473
532
  try {
@@ -527,18 +586,14 @@ requires authentication.
527
586
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
528
587
  }
529
588
  }
530
- const ok = await runBatch(selectedTools, (tool) => {
589
+ await runBatch(selectedTools, (tool) => {
531
590
  const toolArgs = buildScriptArgs(apiKey, {
532
591
  ...urlOpts,
533
592
  clear: opts.clear,
534
593
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
535
594
  });
536
595
  return runScriptPiped(tool.script, toolArgs);
537
- }, { clear: opts.clear });
538
- if (!ok) return;
539
-
540
- console.log('');
541
- output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
596
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
542
597
  return;
543
598
  }
544
599
 
@@ -604,7 +659,8 @@ requires authentication.
604
659
  const { script, label } = SETUP_TOOL_MAP[toolName];
605
660
  const backfill = opts.backfill && scriptSupportsBackfill(script);
606
661
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
607
- await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
662
+ const r = await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
663
+ if (r && r.mdmSkipped) reportMdmSkips([label]);
608
664
  } else if (MODE_TOOLS[toolName]) {
609
665
  const mode = MODE_TOOLS[toolName];
610
666
  if (opts.clear) {
@@ -621,7 +677,8 @@ requires authentication.
621
677
  const { script, label } = SETUP_TOOL_MAP[resolved];
622
678
  const backfill = opts.backfill && scriptSupportsBackfill(script);
623
679
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
624
- await runSetupScript(script, apiKey, { ...urlOpts, backfill });
680
+ const r = await runSetupScript(script, apiKey, { ...urlOpts, backfill });
681
+ if (r && r.mdmSkipped) reportMdmSkips([label]);
625
682
  }
626
683
  } else if (INSTRUCTION_TOOLS[toolName]) {
627
684
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
@@ -664,14 +721,14 @@ requires authentication.
664
721
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
665
722
  }
666
723
  }
667
- const ok = await runBatch(resolvedScripts, (tool) => {
724
+ const { ok } = await runBatch(resolvedScripts, (tool) => {
668
725
  const toolArgs = buildScriptArgs(apiKey, {
669
726
  ...urlOpts,
670
727
  clear: opts.clear,
671
728
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
672
729
  });
673
730
  return runScriptPiped(tool.script, toolArgs);
674
- }, { clear: opts.clear });
731
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
675
732
  if (!ok) return;
676
733
  }
677
734
 
@@ -682,10 +739,6 @@ requires authentication.
682
739
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
683
740
  }
684
741
 
685
- if (resolvedScripts.length > 0) {
686
- console.log('');
687
- output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
688
- }
689
742
  } catch (err) {
690
743
  if (err.message === 'Selection cancelled') return;
691
744
  if (!err.displayed) output.error(err.message);
@@ -832,7 +885,7 @@ Clear examples (no API key required):
832
885
  }
833
886
  }
834
887
 
835
- const ok = await runBatch(
888
+ const { ok } = await runBatch(
836
889
  resolvedTools,
837
890
  (tool) => {
838
891
  const toolArgs = buildScriptArgs(adminApiKey, {
@@ -844,12 +897,9 @@ Clear examples (no API key required):
844
897
  });
845
898
  return runScriptPiped(tool.script, toolArgs);
846
899
  },
847
- { clear: globalOpts.clear }
900
+ { clear: globalOpts.clear, summary: globalOpts.clear ? 'All tools cleared' : 'All tools configured' }
848
901
  );
849
902
  if (!ok) return;
850
-
851
- console.log('');
852
- output.success(globalOpts.clear ? 'All tools cleared' : 'All tools configured');
853
903
  } catch (err) {
854
904
  output.error(err.message);
855
905
  process.exitCode = 1;
package/src/output.js CHANGED
@@ -349,4 +349,4 @@ function multiSelect(message, options) {
349
349
  });
350
350
  }
351
351
 
352
- module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect };
352
+ module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };