unbound-cli 1.1.0 → 1.1.2

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.0",
3
+ "version": "1.1.2",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -23,7 +23,7 @@ function register(program) {
23
23
  'One-step user onboarding: install the default AI tools bundle and run device discovery. ' +
24
24
  'Runs `setup --all` followed by `discover` in a single command.'
25
25
  )
26
- .option('--api-key <key>', 'User API key (or set UNBOUND_API_KEY env var)')
26
+ .option('--api-key <key>', 'User API key (or set UNBOUND_API_KEY env var, or reuse a stored `unbound login` key)')
27
27
  .option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY env var)')
28
28
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
29
29
  .option('--set-cron', 'Set up a daily background job to keep governance up to date')
@@ -33,7 +33,8 @@ function register(program) {
33
33
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
34
34
  .addHelpText('after', `
35
35
  Runs the full onboarding flow for an end user:
36
- 1. Logs in with --api-key and stores credentials.
36
+ 1. Logs in with --api-key and stores credentials (or reuses a stored
37
+ \`unbound login\` key when --api-key is omitted).
37
38
  2. Installs the default tool bundle: ${ALL_TOOLS.join(', ')}.
38
39
  3. Runs device discovery with --discovery-key. With --set-cron, sets up a
39
40
  recurring daily scheduled scan (cross-platform) instead of a one-time scan.
@@ -56,8 +57,10 @@ Examples:
56
57
  .action(async (opts) => {
57
58
  const apiKeyOpt = opts.apiKey || process.env.UNBOUND_API_KEY;
58
59
  const discoveryKeyOpt = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
59
- if (!apiKeyOpt) {
60
- output.error('--api-key is required (or set UNBOUND_API_KEY env var)');
60
+ // A stored `unbound login` key is enough — only demand --api-key when not
61
+ // already logged in. ensureLoggedIn() reuses the stored credential below.
62
+ if (!apiKeyOpt && !config.isLoggedIn()) {
63
+ output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
61
64
  process.exitCode = 1;
62
65
  return;
63
66
  }
@@ -116,7 +119,7 @@ Examples:
116
119
  const { setupScheduledRun } = require('../scheduled');
117
120
  await setupScheduledRun({
118
121
  command: 'onboard',
119
- apiKey: apiKeyOpt,
122
+ apiKey: apiKeyOpt || apiKey,
120
123
  discoveryKey: discoveryKeyOpt,
121
124
  domain: discoveryDomain,
122
125
  skipRunAtLoad: true,
@@ -182,7 +185,7 @@ Examples:
182
185
  'One-step MDM onboarding: install the default MDM tool bundle and run device discovery. ' +
183
186
  'Requires root. Used by organization admins to enroll devices via MDM.'
184
187
  )
185
- .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
188
+ .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key)')
186
189
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
187
190
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
188
191
  .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
@@ -196,11 +199,14 @@ Runs the full MDM onboarding flow for device enrollment:
196
199
 
197
200
  Both steps require root. The admin API key and discovery API key are
198
201
  separate keys obtained from different parts of the Unbound admin dashboard.
202
+ --admin-api-key may be omitted to reuse the key stored by a prior
203
+ \`unbound login\` (run sudo with HOME preserved so the stored key is found).
199
204
 
200
205
  For end-user onboarding (non-MDM), use \`unbound onboard\` instead.
201
206
 
202
207
  Examples:
203
208
  $ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
209
+ $ sudo unbound onboard-mdm --discovery-key <DISCOVERY_KEY> Reuse the stored \`unbound login\` key
204
210
  $ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> --backfill
205
211
  `)
206
212
  .action(async (opts) => {
@@ -221,9 +227,17 @@ Examples:
221
227
 
222
228
  checkRoot('onboard-mdm');
223
229
 
230
+ // Reuse the key stored by `unbound login` when --admin-api-key is omitted.
231
+ const adminApiKey = opts.adminApiKey || config.getApiKey();
232
+ if (!adminApiKey) {
233
+ output.error('--admin-api-key is required (or run `unbound login` first).');
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+
224
238
  console.log('');
225
239
  output.info('Step 1/2: Installing MDM tool bundle');
226
- const ok = await runMdmSetupAllBundle(opts.adminApiKey, {
240
+ const ok = await runMdmSetupAllBundle(adminApiKey, {
227
241
  backendUrl, gatewayUrl, backfill: !!opts.backfill,
228
242
  });
229
243
  if (!ok) return;
@@ -332,6 +332,23 @@ function checkRoot(commandHint = 'setup mdm') {
332
332
  }
333
333
  }
334
334
 
335
+ /**
336
+ * Returns true when the process has the privileges needed to touch system-level
337
+ * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
338
+ * it doubles as an Administrator check — this keeps nuke's scope (and the copy it
339
+ * shows) accurate instead of assuming admin.
340
+ */
341
+ function hasRootPrivileges() {
342
+ if (process.platform === 'win32') {
343
+ try {
344
+ return spawnSync('net', ['session'], { stdio: 'ignore', windowsHide: true }).status === 0;
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+ return typeof process.getuid === 'function' && process.getuid() === 0;
350
+ }
351
+
335
352
  /**
336
353
  * Runs a batch of tools sequentially with spinners.
337
354
  * Stops on first failure. Returns true if all succeeded.
@@ -493,7 +510,7 @@ requires authentication.
493
510
  // No tools specified → interactive multi-select (existing flow)
494
511
  if (tools.length === 0) {
495
512
  const selected = await output.multiSelect(
496
- 'Select tools to set up with Unbound:',
513
+ opts.clear ? 'Select tools to remove Unbound configuration for:' : 'Select tools to set up with Unbound:',
497
514
  SETUP_TOOLS
498
515
  );
499
516
 
@@ -513,14 +530,15 @@ requires authentication.
513
530
  const ok = await runBatch(selectedTools, (tool) => {
514
531
  const toolArgs = buildScriptArgs(apiKey, {
515
532
  ...urlOpts,
533
+ clear: opts.clear,
516
534
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
517
535
  });
518
536
  return runScriptPiped(tool.script, toolArgs);
519
- });
537
+ }, { clear: opts.clear });
520
538
  if (!ok) return;
521
539
 
522
540
  console.log('');
523
- output.success('All tools configured');
541
+ output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
524
542
  return;
525
543
  }
526
544
 
@@ -688,7 +706,7 @@ requires authentication.
688
706
  'Used by organization admins to enroll devices via MDM.'
689
707
  )
690
708
  .argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
691
- .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (not required with --clear)')
709
+ .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key; not required with --clear)')
692
710
  .option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
693
711
  .option('--all', 'Set up all available tools')
694
712
  .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
@@ -708,10 +726,12 @@ Note: claude-code-subscription and claude-code-gateway are mutually exclusive wh
708
726
  setting up; same for codex. Bare claude-code/codex set up subscription mode.
709
727
  When using --all, subscription mode is used by default for Claude Code and Codex.
710
728
 
711
- Setup examples (require --admin-api-key):
729
+ Setup examples (need an admin key — pass --admin-api-key, or omit it to reuse the
730
+ key stored by a prior \`unbound login\`):
712
731
  $ sudo unbound setup mdm --admin-api-key KEY cursor
713
732
  $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
714
733
  $ sudo unbound setup mdm --admin-api-key KEY --all
734
+ $ sudo unbound setup mdm --all Reuse the stored \`unbound login\` key
715
735
  $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
716
736
  Install hooks AND backfill local history
717
737
  $ sudo unbound setup mdm --admin-api-key KEY copilot --backfill
@@ -731,9 +751,12 @@ Clear examples (no API key required):
731
751
  // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
732
752
  const globalOpts = command.optsWithGlobals();
733
753
  // Clearing removes config without calling the API, so a key is only
734
- // required when actually enrolling tools.
735
- if (!globalOpts.clear && !opts.adminApiKey) {
736
- output.error('--admin-api-key is required to set up tools.');
754
+ // required when actually enrolling tools. Fall back to the API key
755
+ // stored by `unbound login` so admins who are already logged in don't
756
+ // have to pass --admin-api-key again.
757
+ const adminApiKey = opts.adminApiKey || config.getApiKey();
758
+ if (!globalOpts.clear && !adminApiKey) {
759
+ output.error('--admin-api-key is required to set up tools (or run `unbound login` first).');
737
760
  process.exitCode = 1;
738
761
  return;
739
762
  }
@@ -812,7 +835,7 @@ Clear examples (no API key required):
812
835
  const ok = await runBatch(
813
836
  resolvedTools,
814
837
  (tool) => {
815
- const toolArgs = buildScriptArgs(opts.adminApiKey, {
838
+ const toolArgs = buildScriptArgs(adminApiKey, {
816
839
  backendUrl,
817
840
  gatewayUrl,
818
841
  clear: globalOpts.clear,
@@ -839,52 +862,49 @@ Clear examples (no API key required):
839
862
  .command('nuke')
840
863
  .alias('uninstall')
841
864
  .description(
842
- 'Remove Unbound and start fresh. By default clears every user-level AND ' +
843
- 'MDM (system-level) tool configuration plus stored credentials (requires ' +
844
- "root). Use --user to clear only this user's tools and credentials (no root)."
865
+ 'Remove Unbound and start fresh: clears AI-tool configuration and deletes ' +
866
+ 'stored credentials. Scope follows your privileges run with sudo to also ' +
867
+ 'remove MDM (system-level) config for all users; without root it clears only ' +
868
+ 'the current user.'
845
869
  )
846
870
  .option('-y, --yes', 'Skip the confirmation prompt')
847
- .option('--user', "Clear only THIS user's tool config and credentials — skip MDM (no root required)")
848
871
  .addHelpText('after', `
849
- Two modes:
850
- Default (requires root) Clears every user-level and MDM (system-level) tool
851
- config for all users on the device, then deletes
852
- credentials.
853
- --user (no root) Clears only the current user's tool config, then
854
- deletes credentials. Leaves MDM config untouched
855
- (removing system-level config needs root).
872
+ Scope is chosen automatically from your privileges:
873
+ Run with sudo (root) Clears every user-level AND MDM (system-level) tool
874
+ config for all users on the device, then deletes
875
+ credentials.
876
+ Run without root Clears only the current user's tool config and
877
+ credentials. MDM (system-level) config is skipped —
878
+ re-run with sudo to remove it too.
856
879
 
857
880
  What it clears:
858
881
  - USER-level tool config — Cursor, GitHub Copilot, Claude Code (subscription +
859
- gateway), Gemini CLI, Codex (subscription + gateway). [both modes]
860
- - MDM (system-level) tool config across all users on the device. [default only]
861
- - Stored credentials and settings (~/.unbound/config.json). [both modes]
882
+ gateway), Gemini CLI, Codex (subscription + gateway).
883
+ - MDM (system-level) tool config across all users [only when run as root]
884
+ - Stored credentials and settings (~/.unbound/config.json).
862
885
 
863
886
  After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
864
887
  fresh. Clearing never contacts the Unbound API, so no API key is needed.
865
888
 
866
889
  Examples:
867
- $ sudo unbound nuke Remove everything on the device (asks to confirm)
868
- $ sudo unbound nuke --yes Remove everything, no confirmation
869
- $ unbound nuke --user Clear only your tools + credentials (no sudo)
870
- $ unbound nuke --user --yes Same, no confirmation
871
- $ sudo unbound uninstall 'uninstall' is an alias for 'nuke'
890
+ $ sudo unbound nuke Remove everything on the device (asks to confirm)
891
+ $ sudo unbound nuke --yes Remove everything, no confirmation
892
+ $ unbound nuke Clear just your tools + credentials (no sudo)
893
+ $ unbound nuke --yes Same, no confirmation
894
+ $ unbound uninstall 'uninstall' is an alias for 'nuke'
872
895
  `)
873
896
  .action(async (opts) => {
874
897
  try {
875
- // Root is only required for the MDM clears (system-level, all users).
876
- // --user mode skips them, so it doesn't need root. On native Windows the
877
- // Python MDM scripts do their own admin check, so skip the uid check there.
878
- if (!opts.user && process.platform !== 'win32' && (typeof process.getuid !== 'function' || process.getuid() !== 0)) {
879
- output.error('nuke removes system-level MDM config for all users, so it must run as root. Run with: sudo unbound nuke — or use `unbound nuke --user` to clear just your own tools and credentials without root.');
880
- process.exitCode = 1;
881
- return;
882
- }
898
+ // Scope follows privileges: with root (or Windows Administrator) we also
899
+ // remove system-level MDM config for all users; otherwise we clear only
900
+ // this user. Detecting elevation up front keeps the confirmation honest
901
+ // (no promising MDM removal we can't perform).
902
+ const includeMdm = hasRootPrivileges();
883
903
 
884
904
  if (!opts.yes) {
885
- output.warn(opts.user
886
- ? 'This removes your user-level Unbound tool configuration and deletes your stored credentials.'
887
- : 'This removes ALL Unbound tool configuration on this device (user-level and MDM) and deletes your stored credentials.');
905
+ output.warn(includeMdm
906
+ ? 'This removes ALL Unbound tool configuration on this device (user-level and MDM) and deletes your stored credentials.'
907
+ : 'This removes your user-level Unbound tool configuration and deletes your stored credentials. (MDM/system-level config needs root and will be skipped — re-run with sudo to remove it too.)');
888
908
  const ok = await confirm('Continue?');
889
909
  if (!ok) {
890
910
  output.info('Cancelled. Nothing was changed.');
@@ -902,11 +922,13 @@ Examples:
902
922
  const userTools = Object.keys(SETUP_TOOL_MAP).map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
903
923
  const userFailed = await clearToolsBestEffort('user', userTools, { mdm: false, backendUrl, gatewayUrl, frontendUrl });
904
924
 
905
- // MDM clears are skipped in --user mode (they need root and touch all users).
925
+ // MDM clears need root and touch all users run them only when we have it.
906
926
  let mdmFailed = [];
907
- if (!opts.user) {
927
+ if (includeMdm) {
908
928
  const mdmTools = Object.keys(MDM_TOOLS).map(name => ({ name, ...MDM_TOOLS[name] }));
909
929
  mdmFailed = await clearToolsBestEffort('mdm', mdmTools, { mdm: true, backendUrl, gatewayUrl });
930
+ } else {
931
+ output.info('Skipped MDM (system-level) config — that needs root. Re-run with sudo to remove it too.');
910
932
  }
911
933
 
912
934
  // Wipe credentials + settings last, regardless of tool-clear outcomes.
@@ -915,7 +937,7 @@ Examples:
915
937
 
916
938
  console.log('');
917
939
  const failed = [...userFailed, ...mdmFailed];
918
- const scope = opts.user ? 'for your user' : 'on this device';
940
+ const scope = includeMdm ? 'on this device' : 'for your user';
919
941
  if (failed.length === 0) {
920
942
  output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
921
943
  } else {
package/src/index.js CHANGED
@@ -82,8 +82,8 @@ TOOL SETUP
82
82
  $ unbound setup --all --clear Remove config for every tool
83
83
 
84
84
  Full uninstall (all tools + credentials):
85
- $ sudo unbound nuke Wipe everything on the device (MDM + user); requires root
86
- $ unbound nuke --user Wipe just your tools + credentials (no sudo)
85
+ $ sudo unbound nuke Wipe everything on the device (MDM + user)
86
+ $ unbound nuke Wipe just your tools + credentials (no sudo)
87
87
 
88
88
  MDM SETUP (admin, requires root)
89
89
  $ sudo unbound setup mdm --admin-api-key KEY --all