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 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`, `setup mdm`, `onboard`, and `onboard-mdm` commands invoke Python setup scripts from the `setup` repo. Those scripts:
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 (requires sudo; MDM scripts ignore --frontend-url since they don't use browser auth)
83
- sudo node src/index.js setup mdm --admin-api-key <ADMIN_KEY> --all --backend-url http://localhost:8000
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-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
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` and `onboard-mdm` also take 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
- - MDM setup scripts (`setup mdm`, `onboard-mdm`) don't do browser auth, so `--frontend-url` is dropped by the CLI; only `--backend-url` and `--gateway-url` are forwarded.
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-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
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 who you are
21
- unbound whoami
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-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
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 whoami` | Show current user, org, and role |
59
- | `unbound status` | Show CLI status and API connectivity |
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 mdm --admin-api-key KEY --all` | Set up all tools |
110
- | `sudo unbound setup mdm --admin-api-key KEY cursor codex-subscription` | Set up specific tools |
111
- | `sudo unbound setup mdm --admin-api-key KEY --clear cursor` | Remove config for specific tools |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.1.8",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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 whoami` to check yours.';
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 whoami' to check.
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 };