unbound-cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -7,6 +7,7 @@ const https = require('https');
7
7
  const config = require('../config');
8
8
  const output = require('../output');
9
9
  const { ensureLoggedIn } = require('../auth');
10
+ const { confirm } = require('../utils');
10
11
 
11
12
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
12
13
 
@@ -59,6 +60,17 @@ const SETUP_TOOL_MAP = {
59
60
  'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
60
61
  };
61
62
 
63
+ /**
64
+ * Resolves the tool list for `setup --all`. Installing uses the subscription
65
+ * bundle (one mode per tool, no Gemini CLI), but clearing must remove EVERY
66
+ * tool Unbound can configure — both modes plus Gemini CLI — so a prior
67
+ * gateway-mode or Gemini setup isn't silently left behind. Mirrors the
68
+ * `setup mdm --all` split.
69
+ */
70
+ function resolveSetupAllTools(clear) {
71
+ return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
72
+ }
73
+
62
74
  // Instruction-only tools (display config values, no setup scripts)
63
75
  const INSTRUCTION_TOOLS = {
64
76
  'roo-code': { label: 'Roo Code', values: (apiKey) => [['API Provider', 'unbound'], ['API Key', apiKey]] },
@@ -341,6 +353,30 @@ async function runBatch(tools, runFn, { clear = false } = {}) {
341
353
  return true;
342
354
  }
343
355
 
356
+ /**
357
+ * Clears a set of tools best-effort: a single tool failing does not abort the
358
+ * rest (unlike runBatch, which stops on first failure). Used by `uninstall`,
359
+ * where the goal is to remove as much as possible. Returns the labels that failed.
360
+ */
361
+ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl, frontendUrl } = {}) {
362
+ const failed = [];
363
+ for (const tool of tools) {
364
+ const s = output.spinner(`Clearing ${layer}: ${tool.label}...`);
365
+ try {
366
+ // frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
367
+ // only appends --domain for non-MDM tools, so MDM clears stay unaffected.
368
+ const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
369
+ await runScriptPiped(tool.script, args);
370
+ s.succeed(`${layer}: ${tool.label}`);
371
+ } catch (err) {
372
+ const reason = (err.message || 'failed').split('\n')[0].slice(0, 80);
373
+ s.fail(`${layer}: ${tool.label} (${reason})`);
374
+ failed.push(tool.label);
375
+ }
376
+ }
377
+ return failed;
378
+ }
379
+
344
380
  function register(program) {
345
381
  const setup = program
346
382
  .command('setup')
@@ -403,6 +439,10 @@ Examples:
403
439
  $ unbound setup copilot --clear Remove GitHub Copilot config
404
440
  $ unbound setup claude-code --clear Remove BOTH Claude Code modes (subscription + gateway)
405
441
  $ unbound setup codex --clear Remove BOTH Codex modes (subscription + gateway)
442
+ $ unbound setup --all --clear Remove EVERY tool's config (both modes + Gemini CLI)
443
+
444
+ To also remove MDM config for all users and wipe credentials, use:
445
+ $ sudo unbound nuke
406
446
 
407
447
  Interactive:
408
448
  $ unbound setup Select tools interactively
@@ -447,7 +487,7 @@ requires authentication.
447
487
  process.exitCode = 1;
448
488
  return;
449
489
  }
450
- tools = [...SETUP_ALL_TOOLS];
490
+ tools = resolveSetupAllTools(opts.clear);
451
491
  }
452
492
 
453
493
  // No tools specified → interactive multi-select (existing flow)
@@ -792,6 +832,100 @@ Clear examples (no API key required):
792
832
  process.exitCode = 1;
793
833
  }
794
834
  });
835
+
836
+ // --- Full uninstall ---
837
+
838
+ program
839
+ .command('nuke')
840
+ .alias('uninstall')
841
+ .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)."
845
+ )
846
+ .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
+ .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).
856
+
857
+ What it clears:
858
+ - 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]
862
+
863
+ After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
864
+ fresh. Clearing never contacts the Unbound API, so no API key is needed.
865
+
866
+ 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'
872
+ `)
873
+ .action(async (opts) => {
874
+ 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
+ }
883
+
884
+ 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.');
888
+ const ok = await confirm('Continue?');
889
+ if (!ok) {
890
+ output.info('Cancelled. Nothing was changed.');
891
+ return;
892
+ }
893
+ }
894
+
895
+ // Resolve URLs before wiping config so the clear scripts get consistent
896
+ // flags. Clearing is URL-independent, but this matches the other clear paths.
897
+ const backendUrl = config.getBaseUrl();
898
+ const gatewayUrl = config.getGatewayUrl();
899
+ const frontendUrl = config.getFrontendUrl();
900
+
901
+ console.log('');
902
+ const userTools = Object.keys(SETUP_TOOL_MAP).map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
903
+ const userFailed = await clearToolsBestEffort('user', userTools, { mdm: false, backendUrl, gatewayUrl, frontendUrl });
904
+
905
+ // MDM clears are skipped in --user mode (they need root and touch all users).
906
+ let mdmFailed = [];
907
+ if (!opts.user) {
908
+ const mdmTools = Object.keys(MDM_TOOLS).map(name => ({ name, ...MDM_TOOLS[name] }));
909
+ mdmFailed = await clearToolsBestEffort('mdm', mdmTools, { mdm: true, backendUrl, gatewayUrl });
910
+ }
911
+
912
+ // Wipe credentials + settings last, regardless of tool-clear outcomes.
913
+ config.clearConfig();
914
+ output.success('Stored credentials and settings removed.');
915
+
916
+ console.log('');
917
+ const failed = [...userFailed, ...mdmFailed];
918
+ const scope = opts.user ? 'for your user' : 'on this device';
919
+ if (failed.length === 0) {
920
+ output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
921
+ } else {
922
+ output.warn(`Done ${scope}, but ${failed.length} tool clear(s) reported issues: ${failed.join(', ')}. Credentials were still removed.`);
923
+ }
924
+ } catch (err) {
925
+ output.error(err.message);
926
+ process.exitCode = 1;
927
+ }
928
+ });
795
929
  }
796
930
 
797
931
  /**
@@ -849,4 +983,5 @@ module.exports = {
849
983
  MDM_ALL_TOOLS,
850
984
  buildScriptArgs,
851
985
  scriptSupportsBackfill,
986
+ resolveSetupAllTools,
852
987
  };
package/src/index.js CHANGED
@@ -73,18 +73,23 @@ TOOL SETUP
73
73
  $ unbound setup kilo-code Show Kilo Code config values
74
74
  $ unbound setup custom-access Show API key and base URL
75
75
 
76
- Remove configuration:
76
+ Remove configuration (no API key needed to clear):
77
77
  $ unbound setup cursor --clear Remove Unbound config for Cursor
78
78
  $ unbound setup copilot --clear Remove Unbound config for GitHub Copilot
79
79
  $ unbound setup claude-code --clear Remove Unbound config for Claude Code
80
80
  $ unbound setup gemini-cli --clear Remove Unbound config for Gemini CLI
81
81
  $ unbound setup codex --clear Remove Unbound config for Codex
82
+ $ unbound setup --all --clear Remove config for every tool
83
+
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)
82
87
 
83
88
  MDM SETUP (admin, requires root)
84
89
  $ sudo unbound setup mdm --admin-api-key KEY --all
85
90
  $ sudo unbound setup mdm --admin-api-key KEY cursor copilot codex-subscription
86
91
  $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription codex-subscription gemini-cli
87
- $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
92
+ $ sudo unbound setup mdm --clear cursor codex-subscription (no API key needed to clear)
88
93
 
89
94
  MDM AI TOOLS DISCOVERY
90
95
  --domain defaults to the configured backend URL (set via "unbound config set-backend-url")
@@ -1,6 +1,6 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
- const { buildScriptArgs, scriptSupportsBackfill } = require('../src/commands/setup');
3
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools } = require('../src/commands/setup');
4
4
 
5
5
  // shellEscape single-quotes every value, so a real key surfaces as
6
6
  // --api-key '<key>' at the head of the argv tail.
@@ -102,3 +102,46 @@ test('buildScriptArgs: result is always trimmed', () => {
102
102
  assert.equal(args, args.trim(), JSON.stringify([key, opts]));
103
103
  }
104
104
  });
105
+
106
+ // WEB-4587: clearing never needs an API key — including the MDM path. The
107
+ // help/examples say so, and buildScriptArgs must back that up: clear+mdm with
108
+ // no key emits --clear, no --api-key, no --domain (MDM has no browser auth).
109
+ test('buildScriptArgs: mdm clear without key emits --clear and no --api-key/--domain', () => {
110
+ const args = buildScriptArgs(null, {
111
+ clear: true,
112
+ mdm: true,
113
+ backendUrl: 'https://backend.acme.com',
114
+ gatewayUrl: 'https://gateway.acme.com',
115
+ frontendUrl: 'https://gateway.acme.com',
116
+ });
117
+ assert.ok(args.includes('--clear'), args);
118
+ assert.ok(!args.includes('--api-key'), args);
119
+ assert.ok(!args.includes('--domain'), args); // mdm:true never passes the frontend URL
120
+ });
121
+
122
+ // WEB-4587 core fix: `setup --all --clear` must remove EVERY tool Unbound can
123
+ // configure, not just the subscription install bundle. Otherwise a prior
124
+ // gateway-mode or Gemini CLI setup is silently left behind.
125
+ test('resolveSetupAllTools(false): install bundle is subscription-only, no gateway/gemini', () => {
126
+ const tools = resolveSetupAllTools(false);
127
+ // Membership + length (order-independent) so reordering SETUP_ALL_TOOLS doesn't break this.
128
+ assert.equal(tools.length, 4, `expected 4 tools, got ${tools.join(',')}`);
129
+ for (const t of ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot']) {
130
+ assert.ok(tools.includes(t), `install bundle missing ${t}: ${tools.join(',')}`);
131
+ }
132
+ assert.ok(!tools.includes('claude-code-gateway'), tools.join(','));
133
+ assert.ok(!tools.includes('codex-gateway'), tools.join(','));
134
+ assert.ok(!tools.includes('gemini-cli'), tools.join(','));
135
+ });
136
+
137
+ test('resolveSetupAllTools(true): clear-all covers every tool incl. gateway modes + gemini', () => {
138
+ const tools = resolveSetupAllTools(true);
139
+ for (const t of ['cursor', 'copilot', 'claude-code-subscription', 'claude-code-gateway',
140
+ 'gemini-cli', 'codex-subscription', 'codex-gateway']) {
141
+ assert.ok(tools.includes(t), `clear-all missing ${t}: ${tools.join(',')}`);
142
+ }
143
+ // The clear set must be a superset of the install bundle.
144
+ for (const t of resolveSetupAllTools(false)) {
145
+ assert.ok(tools.includes(t), `clear-all missing install-bundle tool ${t}`);
146
+ }
147
+ });