unbound-cli 1.0.0 → 1.1.1

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.1",
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]] },
@@ -320,6 +332,23 @@ function checkRoot(commandHint = 'setup mdm') {
320
332
  }
321
333
  }
322
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
+
323
352
  /**
324
353
  * Runs a batch of tools sequentially with spinners.
325
354
  * Stops on first failure. Returns true if all succeeded.
@@ -341,6 +370,30 @@ async function runBatch(tools, runFn, { clear = false } = {}) {
341
370
  return true;
342
371
  }
343
372
 
373
+ /**
374
+ * Clears a set of tools best-effort: a single tool failing does not abort the
375
+ * rest (unlike runBatch, which stops on first failure). Used by `uninstall`,
376
+ * where the goal is to remove as much as possible. Returns the labels that failed.
377
+ */
378
+ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl, frontendUrl } = {}) {
379
+ const failed = [];
380
+ for (const tool of tools) {
381
+ const s = output.spinner(`Clearing ${layer}: ${tool.label}...`);
382
+ try {
383
+ // frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
384
+ // only appends --domain for non-MDM tools, so MDM clears stay unaffected.
385
+ const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
386
+ await runScriptPiped(tool.script, args);
387
+ s.succeed(`${layer}: ${tool.label}`);
388
+ } catch (err) {
389
+ const reason = (err.message || 'failed').split('\n')[0].slice(0, 80);
390
+ s.fail(`${layer}: ${tool.label} (${reason})`);
391
+ failed.push(tool.label);
392
+ }
393
+ }
394
+ return failed;
395
+ }
396
+
344
397
  function register(program) {
345
398
  const setup = program
346
399
  .command('setup')
@@ -403,6 +456,10 @@ Examples:
403
456
  $ unbound setup copilot --clear Remove GitHub Copilot config
404
457
  $ unbound setup claude-code --clear Remove BOTH Claude Code modes (subscription + gateway)
405
458
  $ unbound setup codex --clear Remove BOTH Codex modes (subscription + gateway)
459
+ $ unbound setup --all --clear Remove EVERY tool's config (both modes + Gemini CLI)
460
+
461
+ To also remove MDM config for all users and wipe credentials, use:
462
+ $ sudo unbound nuke
406
463
 
407
464
  Interactive:
408
465
  $ unbound setup Select tools interactively
@@ -447,7 +504,7 @@ requires authentication.
447
504
  process.exitCode = 1;
448
505
  return;
449
506
  }
450
- tools = [...SETUP_ALL_TOOLS];
507
+ tools = resolveSetupAllTools(opts.clear);
451
508
  }
452
509
 
453
510
  // No tools specified → interactive multi-select (existing flow)
@@ -792,6 +849,99 @@ Clear examples (no API key required):
792
849
  process.exitCode = 1;
793
850
  }
794
851
  });
852
+
853
+ // --- Full uninstall ---
854
+
855
+ program
856
+ .command('nuke')
857
+ .alias('uninstall')
858
+ .description(
859
+ 'Remove Unbound and start fresh: clears AI-tool configuration and deletes ' +
860
+ 'stored credentials. Scope follows your privileges — run with sudo to also ' +
861
+ 'remove MDM (system-level) config for all users; without root it clears only ' +
862
+ 'the current user.'
863
+ )
864
+ .option('-y, --yes', 'Skip the confirmation prompt')
865
+ .addHelpText('after', `
866
+ Scope is chosen automatically from your privileges:
867
+ Run with sudo (root) Clears every user-level AND MDM (system-level) tool
868
+ config for all users on the device, then deletes
869
+ credentials.
870
+ Run without root Clears only the current user's tool config and
871
+ credentials. MDM (system-level) config is skipped —
872
+ re-run with sudo to remove it too.
873
+
874
+ What it clears:
875
+ - USER-level tool config — Cursor, GitHub Copilot, Claude Code (subscription +
876
+ gateway), Gemini CLI, Codex (subscription + gateway).
877
+ - MDM (system-level) tool config across all users [only when run as root]
878
+ - Stored credentials and settings (~/.unbound/config.json).
879
+
880
+ After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
881
+ fresh. Clearing never contacts the Unbound API, so no API key is needed.
882
+
883
+ Examples:
884
+ $ sudo unbound nuke Remove everything on the device (asks to confirm)
885
+ $ sudo unbound nuke --yes Remove everything, no confirmation
886
+ $ unbound nuke Clear just your tools + credentials (no sudo)
887
+ $ unbound nuke --yes Same, no confirmation
888
+ $ unbound uninstall 'uninstall' is an alias for 'nuke'
889
+ `)
890
+ .action(async (opts) => {
891
+ try {
892
+ // Scope follows privileges: with root (or Windows Administrator) we also
893
+ // remove system-level MDM config for all users; otherwise we clear only
894
+ // this user. Detecting elevation up front keeps the confirmation honest
895
+ // (no promising MDM removal we can't perform).
896
+ const includeMdm = hasRootPrivileges();
897
+
898
+ if (!opts.yes) {
899
+ output.warn(includeMdm
900
+ ? 'This removes ALL Unbound tool configuration on this device (user-level and MDM) and deletes your stored credentials.'
901
+ : '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.)');
902
+ const ok = await confirm('Continue?');
903
+ if (!ok) {
904
+ output.info('Cancelled. Nothing was changed.');
905
+ return;
906
+ }
907
+ }
908
+
909
+ // Resolve URLs before wiping config so the clear scripts get consistent
910
+ // flags. Clearing is URL-independent, but this matches the other clear paths.
911
+ const backendUrl = config.getBaseUrl();
912
+ const gatewayUrl = config.getGatewayUrl();
913
+ const frontendUrl = config.getFrontendUrl();
914
+
915
+ console.log('');
916
+ const userTools = Object.keys(SETUP_TOOL_MAP).map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
917
+ const userFailed = await clearToolsBestEffort('user', userTools, { mdm: false, backendUrl, gatewayUrl, frontendUrl });
918
+
919
+ // MDM clears need root and touch all users — run them only when we have it.
920
+ let mdmFailed = [];
921
+ if (includeMdm) {
922
+ const mdmTools = Object.keys(MDM_TOOLS).map(name => ({ name, ...MDM_TOOLS[name] }));
923
+ mdmFailed = await clearToolsBestEffort('mdm', mdmTools, { mdm: true, backendUrl, gatewayUrl });
924
+ } else {
925
+ output.info('Skipped MDM (system-level) config — that needs root. Re-run with sudo to remove it too.');
926
+ }
927
+
928
+ // Wipe credentials + settings last, regardless of tool-clear outcomes.
929
+ config.clearConfig();
930
+ output.success('Stored credentials and settings removed.');
931
+
932
+ console.log('');
933
+ const failed = [...userFailed, ...mdmFailed];
934
+ const scope = includeMdm ? 'on this device' : 'for your user';
935
+ if (failed.length === 0) {
936
+ output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
937
+ } else {
938
+ output.warn(`Done ${scope}, but ${failed.length} tool clear(s) reported issues: ${failed.join(', ')}. Credentials were still removed.`);
939
+ }
940
+ } catch (err) {
941
+ output.error(err.message);
942
+ process.exitCode = 1;
943
+ }
944
+ });
795
945
  }
796
946
 
797
947
  /**
@@ -849,4 +999,5 @@ module.exports = {
849
999
  MDM_ALL_TOOLS,
850
1000
  buildScriptArgs,
851
1001
  scriptSupportsBackfill,
1002
+ resolveSetupAllTools,
852
1003
  };
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)
86
+ $ unbound nuke 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
+ });