unbound-cli 1.1.8 → 1.3.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/LOCAL_DEV.md +10 -10
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/doctor.js +198 -0
- package/src/commands/onboard.js +112 -161
- package/src/commands/setup.js +106 -180
- package/src/commands/status.js +42 -2
- package/src/config.js +1 -1
- package/src/index.js +12 -12
- package/src/toolHealth.js +288 -0
- package/test/command-merge.test.js +44 -0
- package/test/doctor-exit.test.js +31 -0
- package/test/onboard-scope.test.js +114 -0
- package/test/tool-health.test.js +210 -0
- package/src/commands/whoami.js +0 -65
package/LOCAL_DEV.md
CHANGED
|
@@ -52,7 +52,7 @@ node src/index.js config reset-gateway-url
|
|
|
52
52
|
|
|
53
53
|
## Point setup scripts to a local backend / frontend
|
|
54
54
|
|
|
55
|
-
The `setup
|
|
55
|
+
The `setup` and `onboard` commands (MDM vs user scope auto-detected from sudo) invoke Python setup scripts from the `setup` repo. Those scripts:
|
|
56
56
|
|
|
57
57
|
- Ping `https://backend.getunbound.ai/api/v1/setup/complete/` when a tool is configured (override with `--backend-url`).
|
|
58
58
|
- Use the frontend URL for the browser-auth callback if `--api-key` is not already stored (override with `--frontend-url`, passed through to the scripts as `--domain`).
|
|
@@ -79,22 +79,22 @@ node src/index.js setup cursor claude-code-gateway --backend-url http://localhos
|
|
|
79
79
|
# Interactive mode (select tools, then apply overrides to all selected)
|
|
80
80
|
node src/index.js setup --backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
81
81
|
|
|
82
|
-
# MDM (
|
|
83
|
-
sudo node src/index.js setup
|
|
82
|
+
# MDM (run setup with sudo — auto-detected; the frontend URL is passed as --frontend-url)
|
|
83
|
+
sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
84
84
|
|
|
85
85
|
# Onboarding (combined setup + discover)
|
|
86
86
|
node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> \
|
|
87
87
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
88
|
-
sudo node src/index.js onboard
|
|
89
|
-
--backend-url http://localhost:8000
|
|
88
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
|
|
89
|
+
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
When omitted, scripts default to `https://backend.getunbound.ai` and the stored frontend URL (or `https://gateway.getunbound.ai`).
|
|
93
93
|
|
|
94
94
|
Notes:
|
|
95
95
|
- `--backend-url` / `--frontend-url` only affect the setup scripts. They do not change the CLI's own API calls (use `config set-url` / `UNBOUND_API_URL` / `config set-frontend-url` / `UNBOUND_FRONTEND_URL` for that).
|
|
96
|
-
- `onboard`
|
|
97
|
-
- MDM
|
|
96
|
+
- `onboard` also takes a visible `--domain <url>` flag — that one is for the **discovery** backend (a separate repo), not the setup scripts' frontend. The two flags don't conflict.
|
|
97
|
+
- Under sudo (MDM), the CLI passes the frontend URL to the setup scripts as `--frontend-url`; without sudo (user scope) it passes it as `--domain`. All three URLs (backend, frontend, gateway) are forwarded and persisted to each user's `config.json`.
|
|
98
98
|
|
|
99
99
|
## Verify config
|
|
100
100
|
|
|
@@ -119,8 +119,8 @@ node src/index.js login --base-url http://localhost:8000 --api-key <your-key>
|
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
# Auth
|
|
122
|
-
node src/index.js whoami
|
|
123
122
|
node src/index.js status
|
|
123
|
+
node src/index.js doctor
|
|
124
124
|
|
|
125
125
|
# Policies — overview
|
|
126
126
|
node src/index.js policy # overview + docs links
|
|
@@ -186,8 +186,8 @@ node src/index.js setup --all --api-key <key>
|
|
|
186
186
|
node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
187
187
|
sudo node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
188
188
|
|
|
189
|
-
# MDM onboarding (admin device enrollment, requires root)
|
|
190
|
-
sudo node src/index.js onboard
|
|
189
|
+
# MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
|
|
190
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
191
191
|
```
|
|
192
192
|
|
|
193
193
|
## Unlink when done
|
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ unbound login
|
|
|
17
17
|
# Or login with an API key (for CI/CD or headless environments)
|
|
18
18
|
unbound login --api-key <your-api-key>
|
|
19
19
|
|
|
20
|
-
# Check
|
|
21
|
-
unbound
|
|
20
|
+
# Check your status, role, and connected tools
|
|
21
|
+
unbound status
|
|
22
22
|
|
|
23
23
|
# List policies
|
|
24
24
|
unbound policy list
|
|
@@ -40,7 +40,7 @@ npm install -g unbound-cli
|
|
|
40
40
|
unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
41
41
|
|
|
42
42
|
# Admin enrolling a device via MDM (requires root)
|
|
43
|
-
sudo unbound onboard
|
|
43
|
+
sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
The user API key and discovery API key are separate — discovery uses its own key that the CLI does not store. Run with `sudo` to let the discovery step scan all users on the device; without it, only the current user is scanned.
|
|
@@ -55,8 +55,8 @@ The user API key and discovery API key are separate — discovery uses its own k
|
|
|
55
55
|
| `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
|
|
56
56
|
| `unbound login --domain <domain>` | Sign in via a custom domain |
|
|
57
57
|
| `unbound logout` | Remove stored credentials |
|
|
58
|
-
| `unbound
|
|
59
|
-
| `unbound
|
|
58
|
+
| `unbound status` | Show CLI status, role, and connected tools |
|
|
59
|
+
| `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
|
|
60
60
|
|
|
61
61
|
### Tool Setup
|
|
62
62
|
|
|
@@ -106,9 +106,9 @@ Configure all users on a device via MDM. Requires root.
|
|
|
106
106
|
|
|
107
107
|
| Command | Description |
|
|
108
108
|
|---------|-------------|
|
|
109
|
-
| `sudo unbound setup
|
|
110
|
-
| `sudo unbound setup
|
|
111
|
-
| `sudo unbound setup
|
|
109
|
+
| `sudo unbound setup --api-key KEY --all` | Set up all tools |
|
|
110
|
+
| `sudo unbound setup --api-key KEY cursor codex-subscription` | Set up specific tools |
|
|
111
|
+
| `sudo unbound setup --clear cursor` | Remove config for specific tools |
|
|
112
112
|
|
|
113
113
|
Available tools: `cursor`, `copilot`, `claude-code-subscription`, `claude-code-gateway`, `gemini-cli`, `codex-subscription`, `codex-gateway`
|
|
114
114
|
|
package/package.json
CHANGED
package/src/commands/chat.js
CHANGED
|
@@ -121,7 +121,7 @@ function normalizeAssistantTurn(turn) {
|
|
|
121
121
|
// backend-supplied strings out of the shell line so copy-pastes stay safe.
|
|
122
122
|
function friendlyChatError(err) {
|
|
123
123
|
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
124
|
-
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound
|
|
124
|
+
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
|
|
125
125
|
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
126
126
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
127
|
}
|
|
@@ -479,7 +479,7 @@ INTERACTIVE SESSION
|
|
|
479
479
|
/exit quit (Ctrl+C also works)
|
|
480
480
|
|
|
481
481
|
NOTES
|
|
482
|
-
* Requires Admin or Manager role in your organization. Run 'unbound
|
|
482
|
+
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
483
483
|
* Conversation history is held in memory only; nothing is ever written to disk.
|
|
484
484
|
* Each -m invocation sends no history; keep each prompt self-contained.
|
|
485
485
|
* -o writes a new file with mode 0600 and refuses to overwrite existing files.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const { spawnSync } = require('child_process');
|
|
2
|
+
const config = require('../config');
|
|
3
|
+
const api = require('../api');
|
|
4
|
+
const output = require('../output');
|
|
5
|
+
const { getDeviceSerial } = require('../device-serial');
|
|
6
|
+
const { detectTools } = require('../toolHealth');
|
|
7
|
+
const { hasRootPrivileges } = require('./setup');
|
|
8
|
+
|
|
9
|
+
// Validate the stored API key against the backend. Returns one of:
|
|
10
|
+
// valid | invalid | not-logged-in | unverified (network/other error).
|
|
11
|
+
async function checkApiKey() {
|
|
12
|
+
if (!config.isLoggedIn()) return { state: 'not-logged-in', detail: 'no API key stored — run `unbound login`' };
|
|
13
|
+
try {
|
|
14
|
+
const deviceSerial = await getDeviceSerial();
|
|
15
|
+
const privileges = await api.get('/api/v1/users/privileges/', { query: { device_serial: deviceSerial } });
|
|
16
|
+
config.backfillUserInfo(privileges);
|
|
17
|
+
return { state: 'valid', detail: 'verified against the backend' };
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// The HTTP status is the reliable signal; the message wording can change.
|
|
20
|
+
if (err && (err.statusCode === 401 || /invalid or expired|401|unauthor/i.test(err.message || ''))) {
|
|
21
|
+
return { state: 'invalid', detail: 'the stored key was rejected (401) — run `unbound login`' };
|
|
22
|
+
}
|
|
23
|
+
return { state: 'unverified', detail: `could not verify (${err.message})` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Exit code for `--fix`: signal/spawn error → 1; otherwise the `setup` status. A
|
|
28
|
+
// successful non-root run that left MDM tools tampered still fails so a CI gate
|
|
29
|
+
// doesn't read the machine as clean. As root those tools were just repaired, so a
|
|
30
|
+
// status-0 run is clean — don't penalize.
|
|
31
|
+
function fixExitCode(status, root, mdmRemaining) {
|
|
32
|
+
if (status == null) return 1;
|
|
33
|
+
return status || (!root && mdmRemaining ? 1 : 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function register(program) {
|
|
37
|
+
program
|
|
38
|
+
.command('doctor')
|
|
39
|
+
.description('Diagnose the local Unbound install: per-tool config, hook script and env wiring, plus API-key validity. Prints how to fix anything broken.')
|
|
40
|
+
.addHelpText('after', `
|
|
41
|
+
For each AI coding tool, doctor verifies:
|
|
42
|
+
Config - the tool's config file contains the Unbound integration
|
|
43
|
+
Hook script - the installed hook/key-helper file exists
|
|
44
|
+
Env wiring - the API key (and gateway URL, for gateway mode) is exported
|
|
45
|
+
|
|
46
|
+
Per-tool status:
|
|
47
|
+
Healthy - everything in place
|
|
48
|
+
Tampered - partially installed (reinstall to fix)
|
|
49
|
+
Not installed - no Unbound integration found
|
|
50
|
+
Managed by MDM- configured org-wide by your administrator
|
|
51
|
+
|
|
52
|
+
Exit code is non-zero when any tool is tampered or the API key is invalid, so
|
|
53
|
+
doctor can gate scripts/CI.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
$ unbound doctor
|
|
57
|
+
$ unbound doctor --fix Reinstall every tampered tool
|
|
58
|
+
$ unbound doctor --json
|
|
59
|
+
`)
|
|
60
|
+
.option('--fix', 'Reinstall every tool reported as tampered')
|
|
61
|
+
.option('--json', 'Output raw JSON')
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
try {
|
|
64
|
+
const apiKey = config.getApiKey();
|
|
65
|
+
const gatewayUrl = config.getGatewayUrl();
|
|
66
|
+
|
|
67
|
+
const spin = output.spinner('Running diagnostics...');
|
|
68
|
+
const key = await checkApiKey();
|
|
69
|
+
const tools = detectTools({ gatewayUrl, apiKey });
|
|
70
|
+
spin.stop();
|
|
71
|
+
|
|
72
|
+
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
73
|
+
const unhealthy = tampered.length > 0 || key.state === 'invalid';
|
|
74
|
+
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
output.json({
|
|
77
|
+
api_key: key,
|
|
78
|
+
tools: tools.map((t) => ({
|
|
79
|
+
tool: t.key,
|
|
80
|
+
label: t.label,
|
|
81
|
+
mode: t.mode,
|
|
82
|
+
status: t.status,
|
|
83
|
+
conflict: !!t.conflict,
|
|
84
|
+
checks: t.checks.map((c) => ({ name: c.name, ok: c.ok, kind: c.kind, detail: c.detail, warn: !!c.warn })),
|
|
85
|
+
})),
|
|
86
|
+
healthy: !unhealthy,
|
|
87
|
+
});
|
|
88
|
+
if (unhealthy) process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const C = output.colors;
|
|
93
|
+
const labelOf = (t) => t.label + (t.mode ? ` (${t.mode})` : '');
|
|
94
|
+
const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
|
|
95
|
+
|
|
96
|
+
console.log('');
|
|
97
|
+
const keyText = {
|
|
98
|
+
valid: C.green('✓ Valid'),
|
|
99
|
+
invalid: C.red('✗ Invalid or expired'),
|
|
100
|
+
'not-logged-in': C.dim('– Not logged in'),
|
|
101
|
+
unverified: `${C.yellow('? Could not verify')} ${C.dim(`(${key.detail})`)}`,
|
|
102
|
+
}[key.state];
|
|
103
|
+
console.log(` ${C.bold('API key'.padEnd(W))} ${keyText}`);
|
|
104
|
+
console.log('');
|
|
105
|
+
|
|
106
|
+
for (const t of tools) {
|
|
107
|
+
let s;
|
|
108
|
+
if (t.status === 'healthy') s = C.green('✓ Healthy');
|
|
109
|
+
else if (t.status === 'managed-by-mdm') s = C.green('✓ Healthy') + C.dim(' · managed by MDM');
|
|
110
|
+
else if (t.status === 'tampered') {
|
|
111
|
+
// Status + reason on one line. Mode sits next to the tool name.
|
|
112
|
+
const reasons = [];
|
|
113
|
+
if (t.conflict) reasons.push('two modes installed');
|
|
114
|
+
for (const c of t.checks) if (!c.ok) reasons.push(c.summary || c.name);
|
|
115
|
+
s = C.red(`✗ Tampered — ${reasons.join(', ')}`);
|
|
116
|
+
} else s = C.dim('○ Not set up');
|
|
117
|
+
console.log(` ${labelOf(t).padEnd(W)} ${s}`);
|
|
118
|
+
}
|
|
119
|
+
console.log('');
|
|
120
|
+
|
|
121
|
+
// Fix routing. User-level tools are fixable by anyone (`unbound doctor
|
|
122
|
+
// --fix`); MDM-managed ones need `sudo`. One sudo run repairs everything
|
|
123
|
+
// (sudo `setup` installs MDM-wide, and on macOS sudo keeps HOME so it sees
|
|
124
|
+
// the user's tools too). When both kinds are broken we show both commands
|
|
125
|
+
// so a user WITHOUT sudo can still repair their own user-level tools.
|
|
126
|
+
const userTampered = tampered.filter((t) => t.scope !== 'mdm');
|
|
127
|
+
const mdmTampered = tampered.filter((t) => t.scope === 'mdm');
|
|
128
|
+
const userFix = userTampered.map((t) => t.key);
|
|
129
|
+
const mdmFix = mdmTampered.map((t) => t.key);
|
|
130
|
+
const userNames = userTampered.map((t) => t.label).join(', ');
|
|
131
|
+
const mdmNames = mdmTampered.map((t) => t.label).join(', ');
|
|
132
|
+
const allFix = tampered.map((t) => t.key);
|
|
133
|
+
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
134
|
+
|
|
135
|
+
if (opts.fix) {
|
|
136
|
+
if (!tampered.length) {
|
|
137
|
+
if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
|
|
138
|
+
else output.success('Nothing to fix — all set up tools are healthy.');
|
|
139
|
+
console.log('');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const root = hasRootPrivileges();
|
|
143
|
+
// sudo → reinstall everything (MDM-wide); without sudo → the user-level
|
|
144
|
+
// tools only, and point at sudo for whatever's org-managed.
|
|
145
|
+
if (!root && !userFix.length) {
|
|
146
|
+
console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
147
|
+
console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
148
|
+
console.log('');
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const fixNow = root ? allFix : userFix;
|
|
153
|
+
output.info(`Reinstalling: ${fixNow.join(', ')}${root ? ' (org-wide, all users)' : ''}`);
|
|
154
|
+
console.log('');
|
|
155
|
+
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
|
|
156
|
+
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
157
|
+
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No `--fix`: report and show the command(s) that fix it.
|
|
162
|
+
if (!anyInstalled && key.state !== 'invalid') {
|
|
163
|
+
console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
|
|
164
|
+
console.log('');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!unhealthy) {
|
|
168
|
+
output.success('All set up tools are healthy.');
|
|
169
|
+
console.log('');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (tampered.length) {
|
|
173
|
+
console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
|
|
174
|
+
if (!mdmFix.length) {
|
|
175
|
+
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
176
|
+
} else if (!userFix.length) {
|
|
177
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
178
|
+
console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
179
|
+
} else {
|
|
180
|
+
// Both: a user without sudo can still fix their own tools; sudo fixes everything.
|
|
181
|
+
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
182
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (key.state === 'invalid') {
|
|
186
|
+
if (!tampered.length) console.log(C.red('✗ Your API key is invalid or expired.'));
|
|
187
|
+
console.log(` Run ${C.bold('unbound login')} to re-authenticate.`);
|
|
188
|
+
}
|
|
189
|
+
console.log('');
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (!err.displayed) output.error(err.message);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { register, fixExitCode };
|