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 +1 -1
- package/src/commands/setup.js +152 -1
- package/src/index.js +7 -2
- package/test/setup-args.test.js +44 -1
package/package.json
CHANGED
package/src/commands/setup.js
CHANGED
|
@@ -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 =
|
|
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 --
|
|
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")
|
package/test/setup-args.test.js
CHANGED
|
@@ -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
|
+
});
|