unbound-cli 1.1.7 → 1.2.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 +9 -9
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/commands/onboard.js +112 -159
- package/src/commands/setup.js +112 -182
- package/src/index.js +8 -8
- package/test/command-merge.test.js +44 -0
- package/test/onboard-scope.test.js +114 -0
- package/test/setup-args.test.js +5 -3
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
|
|
|
@@ -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
|
@@ -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.
|
|
@@ -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/onboard.js
CHANGED
|
@@ -2,7 +2,7 @@ const { Option } = require('commander');
|
|
|
2
2
|
const config = require('../config');
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { ensureLoggedIn } = require('../auth');
|
|
5
|
-
const { runSetupAllBundle, runMdmSetupAllBundle,
|
|
5
|
+
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
6
6
|
const { runDiscoveryScan } = require('./discover');
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -20,52 +20,55 @@ function register(program) {
|
|
|
20
20
|
const onboard = program
|
|
21
21
|
.command('onboard')
|
|
22
22
|
.description(
|
|
23
|
-
'One-step
|
|
24
|
-
'
|
|
23
|
+
'One-step onboarding: install the AI tools bundle and run device discovery. ' +
|
|
24
|
+
'Scope is auto-detected from your privileges — run with sudo to enroll every ' +
|
|
25
|
+
'user on the device (MDM/org scope), or without sudo to set up just the current user.'
|
|
25
26
|
)
|
|
26
|
-
.option('--api-key <key>', '
|
|
27
|
-
.
|
|
27
|
+
.option('--api-key <key>', 'API key (user key, or admin key when run with sudo). Falls back to UNBOUND_API_KEY or a stored `unbound login` key)')
|
|
28
|
+
.addOption(new Option('--admin-api-key <key>', 'Alias for --api-key (back-compat)').hideHelp())
|
|
29
|
+
.option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY)')
|
|
28
30
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
29
|
-
.option('--set-cron', 'Set up a daily background job to keep governance up to date')
|
|
30
|
-
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
31
|
+
.option('--set-cron', 'Set up a daily background job to keep governance up to date (under sudo, schedules a discovery scan only — not a full tool re-install)')
|
|
32
|
+
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
31
33
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
32
34
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
33
35
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
34
36
|
.addHelpText('after', `
|
|
35
|
-
Runs the full onboarding flow
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
Runs the full onboarding flow, auto-detecting scope from your privileges:
|
|
38
|
+
With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and
|
|
39
|
+
scans all users on the device. Without sudo, installs the user bundle
|
|
40
|
+
(${ALL_TOOLS.join(', ')}) and scans the current user.
|
|
41
|
+
|
|
42
|
+
1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
|
|
43
|
+
when --api-key is omitted).
|
|
44
|
+
2. Installs the tool bundle for the detected scope.
|
|
39
45
|
3. Runs device discovery with --discovery-key. With --set-cron, sets up a
|
|
40
46
|
recurring daily scheduled scan (cross-platform) instead of a one-time scan.
|
|
41
47
|
|
|
42
|
-
The
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Run with sudo to let the discovery step scan all users on the device.
|
|
47
|
-
Without sudo, discovery only scans the current user.
|
|
48
|
-
|
|
49
|
-
For admin device enrollment via MDM, use \`unbound onboard-mdm\` instead.
|
|
48
|
+
The API key and discovery API key are separate keys obtained from different
|
|
49
|
+
parts of the Unbound dashboard. Discovery uses its own key that is not stored
|
|
50
|
+
in the CLI config.
|
|
50
51
|
|
|
51
52
|
Examples:
|
|
52
53
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
54
|
+
$ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
53
55
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
54
56
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron
|
|
55
|
-
$ sudo unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
56
57
|
`)
|
|
57
58
|
.action(async (opts) => {
|
|
58
|
-
const apiKeyOpt = opts.apiKey || process.env.UNBOUND_API_KEY;
|
|
59
|
+
const apiKeyOpt = opts.apiKey || opts.adminApiKey || process.env.UNBOUND_API_KEY;
|
|
59
60
|
const discoveryKeyOpt = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!
|
|
63
|
-
output.error('--
|
|
61
|
+
const isMdm = hasRootPrivileges();
|
|
62
|
+
|
|
63
|
+
if (!discoveryKeyOpt) {
|
|
64
|
+
output.error('--discovery-key is required (or set UNBOUND_DISCOVERY_KEY env var)');
|
|
64
65
|
process.exitCode = 1;
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
// User scope needs a key or an existing login; MDM scope resolves the
|
|
69
|
+
// admin key below (it may come from a stored `unbound login` credential).
|
|
70
|
+
if (!isMdm && !apiKeyOpt && !config.isLoggedIn()) {
|
|
71
|
+
output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
|
|
69
72
|
process.exitCode = 1;
|
|
70
73
|
return;
|
|
71
74
|
}
|
|
@@ -73,84 +76,120 @@ Examples:
|
|
|
73
76
|
let setupSucceeded = false;
|
|
74
77
|
let discoverySucceeded = false;
|
|
75
78
|
let discoveryDomain;
|
|
79
|
+
let skippedTools;
|
|
76
80
|
try {
|
|
77
81
|
// Persist URLs first, then login, then setup — order matters so the
|
|
78
82
|
// login validates against the new backend and setup wires tools at the
|
|
79
83
|
// new gateway. setUrls is atomic; a malformed URL throws before any
|
|
80
|
-
// disk write so the three URLs never end up out of sync.
|
|
84
|
+
// disk write so the three URLs never end up out of sync. Prefer the
|
|
85
|
+
// values we JUST persisted over the env-var-aware getters — a stale
|
|
86
|
+
// UNBOUND_*_URL from a prior shell session could otherwise silently
|
|
87
|
+
// shadow the user's explicit --*-url flag.
|
|
81
88
|
const written = config.setUrls({
|
|
82
89
|
backend: opts.backendUrl,
|
|
83
90
|
frontend: opts.frontendUrl,
|
|
84
91
|
gateway: opts.gatewayUrl,
|
|
85
92
|
});
|
|
86
|
-
// Prefer the values we JUST persisted over the env-var-aware getters —
|
|
87
|
-
// a stale UNBOUND_*_URL from a prior shell session could otherwise
|
|
88
|
-
// silently shadow the user's explicit --*-url flag and route login or
|
|
89
|
-
// setup at the wrong tenant.
|
|
90
93
|
const backendUrl = written.base_url || config.getBaseUrl();
|
|
91
94
|
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
92
95
|
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
93
96
|
discoveryDomain = opts.domain || backendUrl;
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (isMdm) {
|
|
99
|
+
// Reuse the key stored by `unbound login` when no key was passed.
|
|
100
|
+
const adminApiKey = apiKeyOpt || config.getApiKey();
|
|
101
|
+
if (!adminApiKey) {
|
|
102
|
+
output.error('--api-key is required (or run `unbound login` first).');
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
console.log('');
|
|
108
|
+
output.info('Step 1/2: Installing MDM tool bundle (all users)');
|
|
109
|
+
const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
|
|
110
|
+
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
111
|
+
});
|
|
112
|
+
if (!ok) return;
|
|
113
|
+
setupSucceeded = true;
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
console.log('');
|
|
116
|
+
output.info('Step 2/2: Running device discovery');
|
|
117
|
+
console.log('');
|
|
118
|
+
await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
|
|
119
|
+
discoverySucceeded = true;
|
|
120
|
+
skippedTools = skipped;
|
|
121
|
+
} else {
|
|
122
|
+
await ensureLoggedIn({
|
|
123
|
+
apiKey: apiKeyOpt,
|
|
124
|
+
baseUrl: written.base_url,
|
|
125
|
+
frontendUrl: written.frontend_url,
|
|
126
|
+
});
|
|
127
|
+
const apiKey = config.getApiKey();
|
|
128
|
+
|
|
129
|
+
console.log('');
|
|
130
|
+
output.info('Step 1/2: Installing tool bundle');
|
|
131
|
+
const { ok, skipped } = await runSetupAllBundle(apiKey, {
|
|
132
|
+
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
133
|
+
});
|
|
134
|
+
if (!ok) return;
|
|
135
|
+
setupSucceeded = true;
|
|
136
|
+
|
|
137
|
+
console.log('');
|
|
138
|
+
output.info('Step 2/2: Running device discovery');
|
|
139
|
+
console.log('');
|
|
140
|
+
await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
|
|
141
|
+
discoverySucceeded = true;
|
|
142
|
+
skippedTools = skipped;
|
|
143
|
+
}
|
|
115
144
|
|
|
116
145
|
if (opts.setCron) {
|
|
117
146
|
console.log('');
|
|
118
|
-
output.info('Setting up daily scheduled run');
|
|
119
147
|
const { setupScheduledRun } = require('../scheduled');
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
if (isMdm) {
|
|
149
|
+
// Under sudo, the recurring job refreshes the device/tool inventory
|
|
150
|
+
// only — it does NOT re-push the full MDM tool bundle daily, and it
|
|
151
|
+
// stores the lower-privilege discovery key, not the admin key.
|
|
152
|
+
output.info('Setting up daily scheduled discovery scan');
|
|
153
|
+
await setupScheduledRun({
|
|
154
|
+
command: 'discover',
|
|
155
|
+
apiKey: discoveryKeyOpt,
|
|
156
|
+
domain: discoveryDomain,
|
|
157
|
+
skipRunAtLoad: true,
|
|
158
|
+
});
|
|
159
|
+
} else {
|
|
160
|
+
output.info('Setting up daily scheduled run');
|
|
161
|
+
await setupScheduledRun({
|
|
162
|
+
command: 'onboard',
|
|
163
|
+
apiKey: apiKeyOpt || config.getApiKey(),
|
|
164
|
+
discoveryKey: discoveryKeyOpt,
|
|
165
|
+
domain: discoveryDomain,
|
|
166
|
+
skipRunAtLoad: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
127
169
|
}
|
|
128
170
|
|
|
129
171
|
console.log('');
|
|
130
|
-
output.success(
|
|
172
|
+
output.success(skippedTools && skippedTools.length
|
|
131
173
|
? 'Onboarding complete — tools managed by MDM were skipped (see above)'
|
|
132
174
|
: 'Onboarding complete');
|
|
133
175
|
} catch (err) {
|
|
134
176
|
if (!err.displayed) output.error(err.message);
|
|
177
|
+
const suffix = domainHintSuffix(discoveryDomain);
|
|
178
|
+
const sudo = isMdm ? 'sudo ' : '';
|
|
135
179
|
if (discoverySucceeded && opts.setCron) {
|
|
136
180
|
// Both setup and discovery completed; only the optional --set-cron
|
|
137
|
-
// step failed.
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// reinstall the user originally asked for, so the correct retry is
|
|
141
|
-
// re-running onboard with both keys.
|
|
142
|
-
const suffix = domainHintSuffix(discoveryDomain);
|
|
181
|
+
// step failed. Retry just the cron: under sudo the scheduled job is a
|
|
182
|
+
// discovery-only scan, so point at `discover --set-cron`; in user
|
|
183
|
+
// scope it re-runs the full onboard (the bundle reinstall is intended).
|
|
143
184
|
console.error(' Setup and discovery completed successfully — only scheduled-run setup failed.');
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const suffix = domainHintSuffix(discoveryDomain);
|
|
147
|
-
const retryCmd = `unbound discover --api-key <DISCOVERY_KEY>${suffix}`;
|
|
148
|
-
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
149
|
-
if (opts.setCron) {
|
|
150
|
-
console.error(` Re-run with: unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
|
|
185
|
+
if (isMdm) {
|
|
186
|
+
console.error(` Re-run just the scheduled scan with: sudo unbound discover --api-key <DISCOVERY_KEY> --set-cron${suffix}`);
|
|
151
187
|
} else {
|
|
152
|
-
console.error(` Re-run
|
|
188
|
+
console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
|
|
153
189
|
}
|
|
190
|
+
} else if (setupSucceeded && !discoverySucceeded) {
|
|
191
|
+
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
192
|
+
console.error(` Re-run discovery only with: ${sudo}unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
|
|
154
193
|
}
|
|
155
194
|
process.exitCode = 1;
|
|
156
195
|
}
|
|
@@ -178,92 +217,6 @@ Examples:
|
|
|
178
217
|
process.exitCode = 1;
|
|
179
218
|
}
|
|
180
219
|
});
|
|
181
|
-
|
|
182
|
-
// --- MDM onboard (separate top-level command, mirrors `unbound onboard`) ---
|
|
183
|
-
|
|
184
|
-
program
|
|
185
|
-
.command('onboard-mdm')
|
|
186
|
-
.description(
|
|
187
|
-
'One-step MDM onboarding: install the default MDM tool bundle and run device discovery. ' +
|
|
188
|
-
'Requires root. Used by organization admins to enroll devices via MDM.'
|
|
189
|
-
)
|
|
190
|
-
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key)')
|
|
191
|
-
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
192
|
-
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
193
|
-
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
194
|
-
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
195
|
-
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
196
|
-
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
197
|
-
.addHelpText('after', `
|
|
198
|
-
Runs the full MDM onboarding flow for device enrollment:
|
|
199
|
-
1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
|
|
200
|
-
2. Runs device discovery against all users on the device.
|
|
201
|
-
|
|
202
|
-
Both steps require root. The admin API key and discovery API key are
|
|
203
|
-
separate keys obtained from different parts of the Unbound admin dashboard.
|
|
204
|
-
--admin-api-key may be omitted to reuse the key stored by a prior
|
|
205
|
-
\`unbound login\` (run sudo with HOME preserved so the stored key is found).
|
|
206
|
-
|
|
207
|
-
For end-user onboarding (non-MDM), use \`unbound onboard\` instead.
|
|
208
|
-
|
|
209
|
-
Examples:
|
|
210
|
-
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
211
|
-
$ sudo unbound onboard-mdm --discovery-key <DISCOVERY_KEY> Reuse the stored \`unbound login\` key
|
|
212
|
-
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
213
|
-
`)
|
|
214
|
-
.action(async (opts) => {
|
|
215
|
-
let setupSucceeded = false;
|
|
216
|
-
let discoveryDomain;
|
|
217
|
-
try {
|
|
218
|
-
// Persist URLs first, then login, then setup — order matters so this
|
|
219
|
-
// MDM run wires tools at the new tenant. Prefer just-written values
|
|
220
|
-
// over env-var-aware getters so a stale UNBOUND_*_URL can't shadow the
|
|
221
|
-
// user's explicit --*-url flag.
|
|
222
|
-
const written = config.setUrls({
|
|
223
|
-
backend: opts.backendUrl,
|
|
224
|
-
gateway: opts.gatewayUrl,
|
|
225
|
-
});
|
|
226
|
-
const backendUrl = written.base_url || config.getBaseUrl();
|
|
227
|
-
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
228
|
-
discoveryDomain = opts.domain || backendUrl;
|
|
229
|
-
|
|
230
|
-
checkRoot('onboard-mdm');
|
|
231
|
-
|
|
232
|
-
// Reuse the key stored by `unbound login` when --admin-api-key is omitted.
|
|
233
|
-
const adminApiKey = opts.adminApiKey || config.getApiKey();
|
|
234
|
-
if (!adminApiKey) {
|
|
235
|
-
output.error('--admin-api-key is required (or run `unbound login` first).');
|
|
236
|
-
process.exitCode = 1;
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
console.log('');
|
|
241
|
-
output.info('Step 1/2: Installing MDM tool bundle');
|
|
242
|
-
const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
|
|
243
|
-
backendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
244
|
-
});
|
|
245
|
-
if (!ok) return;
|
|
246
|
-
setupSucceeded = true;
|
|
247
|
-
|
|
248
|
-
console.log('');
|
|
249
|
-
output.info('Step 2/2: Running device discovery');
|
|
250
|
-
console.log('');
|
|
251
|
-
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
|
|
252
|
-
|
|
253
|
-
console.log('');
|
|
254
|
-
output.success(skipped && skipped.length
|
|
255
|
-
? 'MDM onboarding complete — tools managed by MDM were skipped (see above)'
|
|
256
|
-
: 'MDM onboarding complete');
|
|
257
|
-
} catch (err) {
|
|
258
|
-
if (!err.displayed) output.error(err.message);
|
|
259
|
-
if (setupSucceeded) {
|
|
260
|
-
const suffix = domainHintSuffix(discoveryDomain);
|
|
261
|
-
console.error(' MDM tool setup completed successfully — only discovery failed.');
|
|
262
|
-
console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
|
|
263
|
-
}
|
|
264
|
-
process.exitCode = 1;
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
220
|
}
|
|
268
221
|
|
|
269
222
|
module.exports = { register };
|
package/src/commands/setup.js
CHANGED
|
@@ -45,10 +45,10 @@ const MDM_TOOLS = {
|
|
|
45
45
|
'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
// Default MDM tools for `unbound onboard
|
|
48
|
+
// Default MDM tools for `sudo unbound onboard` (subscription mode for Claude Code/Codex since only one can be active)
|
|
49
49
|
const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
|
|
50
50
|
|
|
51
|
-
// Tools for `unbound setup
|
|
51
|
+
// Tools for `sudo unbound setup --all` — identical to MDM_ALL_TOOLS today; split kept for future flexibility.
|
|
52
52
|
const MDM_SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
|
|
53
53
|
|
|
54
54
|
// Default tools for `unbound onboard` (Cursor, Claude Code hooks, Codex hooks, Copilot hooks; no Gemini CLI).
|
|
@@ -73,7 +73,7 @@ const SETUP_TOOL_MAP = {
|
|
|
73
73
|
* bundle (one mode per tool, no Gemini CLI), but clearing must remove EVERY
|
|
74
74
|
* tool Unbound can configure — both modes plus Gemini CLI — so a prior
|
|
75
75
|
* gateway-mode or Gemini setup isn't silently left behind. Mirrors the
|
|
76
|
-
*
|
|
76
|
+
* MDM `--all` split.
|
|
77
77
|
*/
|
|
78
78
|
function resolveSetupAllTools(clear) {
|
|
79
79
|
return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
|
|
@@ -241,8 +241,9 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
|
|
|
241
241
|
/**
|
|
242
242
|
* Builds the shared argv tail for setup.py invocations. Always passes the
|
|
243
243
|
* tenant URL flags so the script's behavior is not sensitive to drift in its
|
|
244
|
-
* own defaults.
|
|
245
|
-
*
|
|
244
|
+
* own defaults. The frontend URL goes to non-MDM scripts as --domain (also
|
|
245
|
+
* their browser-auth host) and to MDM scripts as --frontend-url (persist-only,
|
|
246
|
+
* no auth flow). Either way it lands in ~/.unbound/config.json as frontend_url.
|
|
246
247
|
*/
|
|
247
248
|
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
|
|
248
249
|
// --clear runs no auth in the Python scripts, so the key may be absent. Omit
|
|
@@ -250,7 +251,7 @@ function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, m
|
|
|
250
251
|
let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
|
|
251
252
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
252
253
|
if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
|
|
253
|
-
if (
|
|
254
|
+
if (frontendUrl) args += mdm ? ` --frontend-url ${shellEscape(frontendUrl)}` : ` --domain ${shellEscape(frontendUrl)}`;
|
|
254
255
|
if (clear) args += ' --clear';
|
|
255
256
|
if (backfill) args += ' --backfill';
|
|
256
257
|
return args.trim();
|
|
@@ -337,18 +338,6 @@ function runScriptPiped(scriptPath, args) {
|
|
|
337
338
|
});
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
/**
|
|
341
|
-
* Checks that the process is running as root (macOS/Linux).
|
|
342
|
-
* Windows admin check is handled by the Python MDM scripts themselves.
|
|
343
|
-
* Pass a customized hint for the calling command (defaults to "setup mdm").
|
|
344
|
-
*/
|
|
345
|
-
function checkRoot(commandHint = 'setup mdm') {
|
|
346
|
-
if (process.platform === 'win32') return;
|
|
347
|
-
if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
|
|
348
|
-
throw new Error(`MDM setup requires root. Run with: sudo unbound ${commandHint} ...`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
341
|
/**
|
|
353
342
|
* Returns true when the process has the privileges needed to touch system-level
|
|
354
343
|
* (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
|
|
@@ -454,9 +443,12 @@ function register(program) {
|
|
|
454
443
|
.argument('[tools...]', 'Tools to set up')
|
|
455
444
|
.description(
|
|
456
445
|
'Configure AI coding tools to use Unbound as their API gateway. ' +
|
|
457
|
-
'Run with no arguments for interactive setup, or specify tools directly.'
|
|
446
|
+
'Run with no arguments for interactive setup, or specify tools directly. ' +
|
|
447
|
+
'Run with sudo to configure every user on the device (MDM/org scope); ' +
|
|
448
|
+
'without sudo, only the current user.'
|
|
458
449
|
)
|
|
459
450
|
.option('--api-key <key>', 'Authenticate with an API key (skips browser login)')
|
|
451
|
+
.addOption(new Option('--admin-api-key <key>', 'Alias for --api-key for MDM enrollment (back-compat)').hideHelp())
|
|
460
452
|
.option('--clear', 'Remove Unbound configuration for the specified tools (no login or API key required)')
|
|
461
453
|
.option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
|
|
462
454
|
.option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
|
|
@@ -466,6 +458,10 @@ function register(program) {
|
|
|
466
458
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
467
459
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
468
460
|
.addHelpText('after', `
|
|
461
|
+
Scope is chosen automatically from your privileges:
|
|
462
|
+
Run with sudo to configure every user on the device (MDM/org scope);
|
|
463
|
+
without sudo, only the current user is configured.
|
|
464
|
+
|
|
469
465
|
Available tools:
|
|
470
466
|
cursor Cursor IDE
|
|
471
467
|
copilot GitHub Copilot
|
|
@@ -495,6 +491,11 @@ Examples:
|
|
|
495
491
|
$ unbound setup --all Set up the default bundle
|
|
496
492
|
$ unbound setup --all --api-key <key> Login + set up the bundle
|
|
497
493
|
|
|
494
|
+
Configure all users on the device (MDM/org scope — run with sudo):
|
|
495
|
+
$ sudo unbound setup --all Configure all users (MDM)
|
|
496
|
+
$ sudo unbound setup cursor codex-subscription Configure specific tools for all users
|
|
497
|
+
$ sudo unbound setup --clear --all Remove config for all users
|
|
498
|
+
|
|
498
499
|
Seed historical sessions (Claude Code / Codex subscription mode + Copilot):
|
|
499
500
|
$ unbound setup claude-code --subscription --backfill Install hooks AND backfill local history
|
|
500
501
|
$ unbound setup codex --subscription --backfill Install hooks AND backfill local history
|
|
@@ -544,6 +545,95 @@ must update the MDM configuration.
|
|
|
544
545
|
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
545
546
|
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
546
547
|
|
|
548
|
+
// Scope is auto-detected from privileges: with sudo/root we configure
|
|
549
|
+
// every user on the device (MDM scope); without it, just the current
|
|
550
|
+
// user. URLs were already persisted above and apply to both scopes.
|
|
551
|
+
const isMdm = hasRootPrivileges();
|
|
552
|
+
if (isMdm) {
|
|
553
|
+
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
554
|
+
const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
|
|
555
|
+
if (!opts.clear && !adminApiKey) {
|
|
556
|
+
output.error('--api-key is required to set up tools (or run `unbound login` first).');
|
|
557
|
+
process.exitCode = 1;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (opts.all && tools.length > 0) {
|
|
561
|
+
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
|
562
|
+
process.exitCode = 1;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
let toolNames;
|
|
566
|
+
if (opts.all) {
|
|
567
|
+
// --clear --all wipes every tool (both modes); setup --all uses the
|
|
568
|
+
// subscription-default bundle (can't enroll both modes at once).
|
|
569
|
+
toolNames = opts.clear ? Object.keys(MDM_TOOLS) : MDM_SETUP_ALL_TOOLS;
|
|
570
|
+
} else if (tools.length > 0) {
|
|
571
|
+
toolNames = tools;
|
|
572
|
+
} else {
|
|
573
|
+
output.error('Specify tools to set up, or use --all.');
|
|
574
|
+
console.error(' Available: ' + mdmToolNames);
|
|
575
|
+
process.exitCode = 1;
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Bare claude-code/codex have no interactive mode prompt under MDM:
|
|
579
|
+
// --clear removes both modes; setup honors --gateway/--subscription
|
|
580
|
+
// (matching user scope) and defaults to subscription.
|
|
581
|
+
if (!opts.clear && opts.subscription && opts.gateway) {
|
|
582
|
+
output.error('Cannot use both --subscription and --gateway. Choose one.');
|
|
583
|
+
process.exitCode = 1;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
toolNames = [...new Set(toolNames.flatMap(name => {
|
|
587
|
+
const mode = MODE_TOOLS[name];
|
|
588
|
+
if (!mode) return [name];
|
|
589
|
+
if (opts.clear) return [mode.subscription, mode.gateway];
|
|
590
|
+
return opts.gateway ? [mode.gateway] : [mode.subscription];
|
|
591
|
+
}))];
|
|
592
|
+
const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
|
|
593
|
+
if (invalid.length > 0) {
|
|
594
|
+
output.error(`Unknown tool(s): ${invalid.join(', ')}`);
|
|
595
|
+
console.error(' Available: ' + mdmToolNames);
|
|
596
|
+
process.exitCode = 1;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!opts.clear) {
|
|
600
|
+
if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
|
|
601
|
+
output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
|
|
602
|
+
process.exitCode = 1;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
|
|
606
|
+
output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
|
|
607
|
+
process.exitCode = 1;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
612
|
+
console.log('');
|
|
613
|
+
if (opts.backfill) {
|
|
614
|
+
for (const tool of resolvedTools) {
|
|
615
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const { ok } = await runBatch(
|
|
619
|
+
resolvedTools,
|
|
620
|
+
(tool) => {
|
|
621
|
+
const toolArgs = buildScriptArgs(adminApiKey, {
|
|
622
|
+
backendUrl,
|
|
623
|
+
frontendUrl,
|
|
624
|
+
gatewayUrl,
|
|
625
|
+
clear: opts.clear,
|
|
626
|
+
mdm: true,
|
|
627
|
+
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
628
|
+
});
|
|
629
|
+
return runScriptPiped(tool.script, toolArgs);
|
|
630
|
+
},
|
|
631
|
+
{ clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' }
|
|
632
|
+
);
|
|
633
|
+
if (!ok) return;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
547
637
|
// Clearing config needs no credentials — the setup scripts remove
|
|
548
638
|
// files without calling the API — so don't force a login for --clear.
|
|
549
639
|
if (!opts.clear) {
|
|
@@ -746,166 +836,6 @@ must update the MDM configuration.
|
|
|
746
836
|
}
|
|
747
837
|
});
|
|
748
838
|
|
|
749
|
-
// --- MDM setup ---
|
|
750
|
-
|
|
751
|
-
// Bare claude-code/codex are accepted too: with --clear they remove both
|
|
752
|
-
// modes; for setup they default to subscription (MDM has no mode prompt).
|
|
753
|
-
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
754
|
-
|
|
755
|
-
setup
|
|
756
|
-
.command('mdm')
|
|
757
|
-
.description(
|
|
758
|
-
'MDM setup: configure all users on this device. Requires root. ' +
|
|
759
|
-
'Used by organization admins to enroll devices via MDM.'
|
|
760
|
-
)
|
|
761
|
-
.argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
|
|
762
|
-
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key; not required with --clear)')
|
|
763
|
-
.option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
|
|
764
|
-
.option('--all', 'Set up all available tools')
|
|
765
|
-
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
|
|
766
|
-
.addHelpText('after', `
|
|
767
|
-
Available tools:
|
|
768
|
-
cursor Cursor IDE
|
|
769
|
-
copilot GitHub Copilot
|
|
770
|
-
claude-code-subscription Claude Code with your own subscription (hooks only)
|
|
771
|
-
claude-code-gateway Claude Code with Unbound as AI provider
|
|
772
|
-
claude-code Both Claude Code modes (clears both; sets up subscription)
|
|
773
|
-
gemini-cli Gemini CLI
|
|
774
|
-
codex-subscription Codex with your own subscription (hooks only)
|
|
775
|
-
codex-gateway Codex with Unbound as AI provider
|
|
776
|
-
codex Both Codex modes (clears both; sets up subscription)
|
|
777
|
-
|
|
778
|
-
Note: claude-code-subscription and claude-code-gateway are mutually exclusive when
|
|
779
|
-
setting up; same for codex. Bare claude-code/codex set up subscription mode.
|
|
780
|
-
When using --all, subscription mode is used by default for Claude Code and Codex.
|
|
781
|
-
|
|
782
|
-
Setup examples (need an admin key — pass --admin-api-key, or omit it to reuse the
|
|
783
|
-
key stored by a prior \`unbound login\`):
|
|
784
|
-
$ sudo unbound setup mdm --admin-api-key KEY cursor
|
|
785
|
-
$ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
|
|
786
|
-
$ sudo unbound setup mdm --admin-api-key KEY --all
|
|
787
|
-
$ sudo unbound setup mdm --all Reuse the stored \`unbound login\` key
|
|
788
|
-
$ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
|
|
789
|
-
Install hooks AND backfill local history
|
|
790
|
-
$ sudo unbound setup mdm --admin-api-key KEY copilot --backfill
|
|
791
|
-
Install Copilot hooks AND backfill local history
|
|
792
|
-
|
|
793
|
-
Clear examples (no API key required):
|
|
794
|
-
$ sudo unbound setup mdm --clear cursor
|
|
795
|
-
$ sudo unbound setup mdm --clear claude-code Clears BOTH Claude Code modes
|
|
796
|
-
$ sudo unbound setup mdm --clear codex Clears BOTH Codex modes
|
|
797
|
-
$ sudo unbound setup mdm --clear --all Clears every tool
|
|
798
|
-
`)
|
|
799
|
-
.action(async (tools, opts, command) => {
|
|
800
|
-
try {
|
|
801
|
-
checkRoot();
|
|
802
|
-
// --all and --clear are defined on both this command and the parent `setup` command;
|
|
803
|
-
// --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
|
|
804
|
-
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
805
|
-
const globalOpts = command.optsWithGlobals();
|
|
806
|
-
// Clearing removes config without calling the API, so a key is only
|
|
807
|
-
// required when actually enrolling tools. Fall back to the API key
|
|
808
|
-
// stored by `unbound login` so admins who are already logged in don't
|
|
809
|
-
// have to pass --admin-api-key again.
|
|
810
|
-
const adminApiKey = opts.adminApiKey || config.getApiKey();
|
|
811
|
-
if (!globalOpts.clear && !adminApiKey) {
|
|
812
|
-
output.error('--admin-api-key is required to set up tools (or run `unbound login` first).');
|
|
813
|
-
process.exitCode = 1;
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
// Persist URLs first so this MDM run wires tools at the new tenant
|
|
817
|
-
// and any subsequent non-MDM command on the same machine inherits.
|
|
818
|
-
// Prefer just-persisted values over env-var-aware getters so a stale
|
|
819
|
-
// UNBOUND_*_URL can't shadow the explicit --*-url flag.
|
|
820
|
-
const written = config.setUrls({
|
|
821
|
-
backend: globalOpts.backendUrl,
|
|
822
|
-
gateway: globalOpts.gatewayUrl,
|
|
823
|
-
});
|
|
824
|
-
const backendUrl = written.base_url || config.getBaseUrl();
|
|
825
|
-
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
826
|
-
|
|
827
|
-
if (globalOpts.all && tools.length > 0) {
|
|
828
|
-
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
|
829
|
-
process.exitCode = 1;
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
let toolNames;
|
|
834
|
-
if (globalOpts.all) {
|
|
835
|
-
// --clear --all wipes every tool, including both Claude Code/Codex modes.
|
|
836
|
-
// Setup --all uses the subscription-default bundle (can't enroll both modes).
|
|
837
|
-
toolNames = globalOpts.clear ? Object.keys(MDM_TOOLS) : MDM_SETUP_ALL_TOOLS;
|
|
838
|
-
} else if (tools.length > 0) {
|
|
839
|
-
toolNames = tools;
|
|
840
|
-
} else {
|
|
841
|
-
output.error('Specify tools to set up, or use --all.');
|
|
842
|
-
console.error(' Available: ' + mdmToolNames);
|
|
843
|
-
process.exitCode = 1;
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// Expand bare claude-code/codex (MDM has no interactive mode prompt):
|
|
848
|
-
// --clear removes both modes; setup defaults to subscription, matching --all.
|
|
849
|
-
toolNames = [...new Set(toolNames.flatMap(name => {
|
|
850
|
-
const mode = MODE_TOOLS[name];
|
|
851
|
-
if (!mode) return [name];
|
|
852
|
-
return globalOpts.clear ? [mode.subscription, mode.gateway] : [mode.subscription];
|
|
853
|
-
}))];
|
|
854
|
-
|
|
855
|
-
const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
|
|
856
|
-
if (invalid.length > 0) {
|
|
857
|
-
output.error(`Unknown tool(s): ${invalid.join(', ')}`);
|
|
858
|
-
console.error(' Available: ' + mdmToolNames);
|
|
859
|
-
process.exitCode = 1;
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// Mode mutual-exclusivity only applies when setting up — clearing both
|
|
864
|
-
// modes at once is valid (and is what bare claude-code/codex --clear does).
|
|
865
|
-
if (!globalOpts.clear) {
|
|
866
|
-
if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
|
|
867
|
-
output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
|
|
868
|
-
process.exitCode = 1;
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
|
|
873
|
-
output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
|
|
874
|
-
process.exitCode = 1;
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
880
|
-
console.log('');
|
|
881
|
-
|
|
882
|
-
if (globalOpts.backfill) {
|
|
883
|
-
for (const tool of resolvedTools) {
|
|
884
|
-
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const { ok } = await runBatch(
|
|
889
|
-
resolvedTools,
|
|
890
|
-
(tool) => {
|
|
891
|
-
const toolArgs = buildScriptArgs(adminApiKey, {
|
|
892
|
-
backendUrl,
|
|
893
|
-
gatewayUrl,
|
|
894
|
-
clear: globalOpts.clear,
|
|
895
|
-
mdm: true,
|
|
896
|
-
backfill: globalOpts.backfill && scriptSupportsBackfill(tool.script),
|
|
897
|
-
});
|
|
898
|
-
return runScriptPiped(tool.script, toolArgs);
|
|
899
|
-
},
|
|
900
|
-
{ clear: globalOpts.clear, summary: globalOpts.clear ? 'All tools cleared' : 'All tools configured' }
|
|
901
|
-
);
|
|
902
|
-
if (!ok) return;
|
|
903
|
-
} catch (err) {
|
|
904
|
-
output.error(err.message);
|
|
905
|
-
process.exitCode = 1;
|
|
906
|
-
}
|
|
907
|
-
});
|
|
908
|
-
|
|
909
839
|
// --- Full uninstall ---
|
|
910
840
|
|
|
911
841
|
program
|
|
@@ -1030,7 +960,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
1030
960
|
* Caller must ensure the process is running as root.
|
|
1031
961
|
* Returns true on success, false on failure.
|
|
1032
962
|
*/
|
|
1033
|
-
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl, backfill = false } = {}) {
|
|
963
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
1034
964
|
const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
1035
965
|
if (backfill) {
|
|
1036
966
|
for (const tool of resolvedTools) {
|
|
@@ -1039,7 +969,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl, backf
|
|
|
1039
969
|
}
|
|
1040
970
|
return runBatch(resolvedTools, (tool) => {
|
|
1041
971
|
const args = buildScriptArgs(adminApiKey, {
|
|
1042
|
-
backendUrl, gatewayUrl, mdm: true,
|
|
972
|
+
backendUrl, frontendUrl, gatewayUrl, mdm: true,
|
|
1043
973
|
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
1044
974
|
});
|
|
1045
975
|
return runScriptPiped(tool.script, args);
|
|
@@ -1050,7 +980,7 @@ module.exports = {
|
|
|
1050
980
|
register,
|
|
1051
981
|
runSetupAllBundle,
|
|
1052
982
|
runMdmSetupAllBundle,
|
|
1053
|
-
|
|
983
|
+
hasRootPrivileges,
|
|
1054
984
|
ALL_TOOLS,
|
|
1055
985
|
MDM_ALL_TOOLS,
|
|
1056
986
|
buildScriptArgs,
|
package/src/index.js
CHANGED
|
@@ -44,9 +44,9 @@ AUTHENTICATION
|
|
|
44
44
|
Or set URLs separately (any time):
|
|
45
45
|
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
46
46
|
|
|
47
|
-
ONBOARDING (one-step install + discover)
|
|
48
|
-
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
49
|
-
$ sudo unbound onboard
|
|
47
|
+
ONBOARDING (one-step install + discover; scope auto-detected from sudo)
|
|
48
|
+
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> Current user
|
|
49
|
+
$ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> All users (MDM)
|
|
50
50
|
|
|
51
51
|
TOOL SETUP
|
|
52
52
|
$ unbound setup Select and install multiple tools interactively
|
|
@@ -85,11 +85,11 @@ TOOL SETUP
|
|
|
85
85
|
$ sudo unbound nuke Wipe everything on the device (MDM + user)
|
|
86
86
|
$ unbound nuke Wipe just your tools + credentials (no sudo)
|
|
87
87
|
|
|
88
|
-
MDM SETUP (
|
|
89
|
-
$ sudo unbound setup
|
|
90
|
-
$ sudo unbound setup
|
|
91
|
-
$ sudo unbound setup
|
|
92
|
-
$ sudo unbound setup
|
|
88
|
+
MDM SETUP (all users on the device — run setup with sudo)
|
|
89
|
+
$ sudo unbound setup --all --api-key KEY
|
|
90
|
+
$ sudo unbound setup cursor copilot codex-subscription --api-key KEY
|
|
91
|
+
$ sudo unbound setup claude-code-subscription codex-subscription gemini-cli --api-key KEY
|
|
92
|
+
$ sudo unbound setup --clear cursor codex-subscription (no API key needed to clear)
|
|
93
93
|
|
|
94
94
|
MDM AI TOOLS DISCOVERY
|
|
95
95
|
--domain defaults to the configured backend URL (set via "unbound config set-backend-url")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const setup = require('../src/commands/setup');
|
|
5
|
+
|
|
6
|
+
// WEB-4757: `onboard`/`setup` are single commands; MDM vs user scope is
|
|
7
|
+
// auto-detected from sudo/root, so the separate `onboard-mdm` and `setup mdm`
|
|
8
|
+
// commands no longer exist. These guard against a regression that re-splits or
|
|
9
|
+
// drops them.
|
|
10
|
+
|
|
11
|
+
function buildProgram() {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
require('../src/commands/onboard').register(program);
|
|
14
|
+
setup.register(program);
|
|
15
|
+
return program;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('WEB-4757: onboard/setup are single top-level commands, no -mdm variants', () => {
|
|
19
|
+
const top = buildProgram().commands.map((c) => c.name());
|
|
20
|
+
assert.ok(top.includes('onboard'), 'onboard is registered');
|
|
21
|
+
assert.ok(top.includes('setup'), 'setup is registered');
|
|
22
|
+
assert.ok(!top.includes('onboard-mdm'), 'onboard-mdm is gone');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('WEB-4757: setup has no `mdm` subcommand; onboard keeps `unschedule`', () => {
|
|
26
|
+
const program = buildProgram();
|
|
27
|
+
const setupCmd = program.commands.find((c) => c.name() === 'setup');
|
|
28
|
+
const onboardCmd = program.commands.find((c) => c.name() === 'onboard');
|
|
29
|
+
assert.ok(!setupCmd.commands.some((c) => c.name() === 'mdm'), 'setup mdm subcommand is gone');
|
|
30
|
+
assert.ok(onboardCmd.commands.some((c) => c.name() === 'unschedule'), 'onboard unschedule is kept');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('WEB-4757: onboard/setup accept --admin-api-key as a back-compat alias', () => {
|
|
34
|
+
const program = buildProgram();
|
|
35
|
+
const onboardCmd = program.commands.find((c) => c.name() === 'onboard');
|
|
36
|
+
const setupCmd = program.commands.find((c) => c.name() === 'setup');
|
|
37
|
+
assert.ok(onboardCmd.options.some((o) => o.long === '--admin-api-key'), 'onboard --admin-api-key');
|
|
38
|
+
assert.ok(setupCmd.options.some((o) => o.long === '--admin-api-key'), 'setup --admin-api-key');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('WEB-4757: scope helper is exported; the throwing checkRoot is gone', () => {
|
|
42
|
+
assert.equal(typeof setup.hasRootPrivileges, 'function');
|
|
43
|
+
assert.equal(setup.checkRoot, undefined);
|
|
44
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
|
|
5
|
+
// WEB-4757: scope is auto-detected from sudo/root. These tests verify the
|
|
6
|
+
// permission primitive (hasRootPrivileges) and that `onboard` actually routes
|
|
7
|
+
// to the MDM bundle when root and the user bundle when not — without touching
|
|
8
|
+
// the network or running any setup script (all downstream deps are stubbed).
|
|
9
|
+
|
|
10
|
+
// ---- 1. The sudo permission check itself ----
|
|
11
|
+
test('hasRootPrivileges: true when effective uid is 0 (sudo/root)', () => {
|
|
12
|
+
delete require.cache[require.resolve('../src/commands/setup')];
|
|
13
|
+
const setup = require('../src/commands/setup');
|
|
14
|
+
const orig = process.getuid;
|
|
15
|
+
try {
|
|
16
|
+
process.getuid = () => 0;
|
|
17
|
+
assert.equal(setup.hasRootPrivileges(), true);
|
|
18
|
+
} finally {
|
|
19
|
+
process.getuid = orig;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('hasRootPrivileges: false when effective uid is not 0 (no sudo)', () => {
|
|
24
|
+
delete require.cache[require.resolve('../src/commands/setup')];
|
|
25
|
+
const setup = require('../src/commands/setup');
|
|
26
|
+
const orig = process.getuid;
|
|
27
|
+
try {
|
|
28
|
+
process.getuid = () => 1000;
|
|
29
|
+
assert.equal(setup.hasRootPrivileges(), false);
|
|
30
|
+
} finally {
|
|
31
|
+
process.getuid = orig;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ---- 2. onboard routes by detected scope ----
|
|
36
|
+
// Reloads the module graph with stubbed deps so we can observe which bundle
|
|
37
|
+
// helper the single `onboard` command invokes.
|
|
38
|
+
async function runOnboard(isRoot, { setCron = false } = {}) {
|
|
39
|
+
for (const m of [
|
|
40
|
+
'../src/commands/onboard',
|
|
41
|
+
'../src/commands/setup',
|
|
42
|
+
'../src/commands/discover',
|
|
43
|
+
'../src/auth',
|
|
44
|
+
'../src/config',
|
|
45
|
+
'../src/output',
|
|
46
|
+
'../src/scheduled',
|
|
47
|
+
]) {
|
|
48
|
+
delete require.cache[require.resolve(m)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null };
|
|
52
|
+
const setup = require('../src/commands/setup');
|
|
53
|
+
const discover = require('../src/commands/discover');
|
|
54
|
+
const auth = require('../src/auth');
|
|
55
|
+
const config = require('../src/config');
|
|
56
|
+
const output = require('../src/output');
|
|
57
|
+
const scheduled = require('../src/scheduled');
|
|
58
|
+
|
|
59
|
+
setup.hasRootPrivileges = () => isRoot;
|
|
60
|
+
setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
|
|
61
|
+
setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
|
|
62
|
+
discover.runDiscoveryScan = async () => { calls.discovery++; };
|
|
63
|
+
auth.ensureLoggedIn = async () => { calls.loggedIn++; };
|
|
64
|
+
scheduled.setupScheduledRun = async (o) => { calls.cron = o; };
|
|
65
|
+
config.setUrls = () => ({});
|
|
66
|
+
config.getApiKey = () => 'resolved-key';
|
|
67
|
+
config.getBaseUrl = () => 'https://b.acme';
|
|
68
|
+
config.getFrontendUrl = () => 'https://f.acme';
|
|
69
|
+
config.getGatewayUrl = () => 'https://g.acme';
|
|
70
|
+
config.isLoggedIn = () => true;
|
|
71
|
+
for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
|
|
72
|
+
|
|
73
|
+
const { register } = require('../src/commands/onboard');
|
|
74
|
+
const program = new Command();
|
|
75
|
+
program.exitOverride();
|
|
76
|
+
register(program);
|
|
77
|
+
const argv = ['node', 'unbound', 'onboard', '--api-key', 'k', '--discovery-key', 'd'];
|
|
78
|
+
if (setCron) argv.push('--set-cron');
|
|
79
|
+
await program.parseAsync(argv);
|
|
80
|
+
return calls;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('onboard with sudo/root → installs the MDM bundle (all users), no login', async () => {
|
|
84
|
+
const calls = await runOnboard(true);
|
|
85
|
+
assert.equal(calls.mdm, 1, 'MDM bundle installed');
|
|
86
|
+
assert.equal(calls.user, 0, 'user bundle NOT installed');
|
|
87
|
+
assert.equal(calls.loggedIn, 0, 'MDM scope does not run ensureLoggedIn');
|
|
88
|
+
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('onboard without sudo → logs in and installs the user bundle', async () => {
|
|
92
|
+
const calls = await runOnboard(false);
|
|
93
|
+
assert.equal(calls.user, 1, 'user bundle installed');
|
|
94
|
+
assert.equal(calls.mdm, 0, 'MDM bundle NOT installed');
|
|
95
|
+
assert.equal(calls.loggedIn, 1, 'user scope runs ensureLoggedIn');
|
|
96
|
+
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --set-cron under sudo schedules discovery only (not a daily full MDM
|
|
100
|
+
// re-install) and stores the discovery key, never the admin key.
|
|
101
|
+
test('onboard --set-cron with sudo → schedules discovery-only with the discovery key', async () => {
|
|
102
|
+
const calls = await runOnboard(true, { setCron: true });
|
|
103
|
+
assert.ok(calls.cron, 'a scheduled run was set up');
|
|
104
|
+
assert.equal(calls.cron.command, 'discover', 'schedules discover, not full onboard');
|
|
105
|
+
assert.equal(calls.cron.apiKey, 'd', 'uses the discovery key, not the admin key');
|
|
106
|
+
assert.equal(calls.cron.discoveryKey, undefined, 'no separate admin/discovery key passed');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('onboard --set-cron without sudo → schedules the full onboard run', async () => {
|
|
110
|
+
const calls = await runOnboard(false, { setCron: true });
|
|
111
|
+
assert.ok(calls.cron, 'a scheduled run was set up');
|
|
112
|
+
assert.equal(calls.cron.command, 'onboard', 'user scope keeps the full onboard cron');
|
|
113
|
+
assert.equal(calls.cron.discoveryKey, 'd', 'discovery key forwarded for the onboard cron');
|
|
114
|
+
});
|
package/test/setup-args.test.js
CHANGED
|
@@ -70,14 +70,16 @@ test('scriptSupportsBackfill: cursor and gateway-mode scripts are unsupported',
|
|
|
70
70
|
assert.ok(!scriptSupportsBackfill('gemini-cli/gateway/setup.py'));
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
// MDM scripts have no browser-auth flow, so
|
|
74
|
-
//
|
|
75
|
-
|
|
73
|
+
// MDM scripts have no browser-auth flow, so the frontend URL goes via
|
|
74
|
+
// --frontend-url (persist-only), never --domain. It must still be passed so
|
|
75
|
+
// frontend_url lands in each user's ~/.unbound/config.json.
|
|
76
|
+
test('buildScriptArgs: mdm:true passes frontend as --frontend-url, not --domain', () => {
|
|
76
77
|
const args = buildScriptArgs('sk-x', {
|
|
77
78
|
mdm: true,
|
|
78
79
|
frontendUrl: 'https://gateway.acme.com',
|
|
79
80
|
});
|
|
80
81
|
assert.ok(!args.includes('--domain'), args);
|
|
82
|
+
assert.ok(args.includes("--frontend-url 'https://gateway.acme.com'"), args);
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
test('buildScriptArgs: non-mdm includes --domain when frontendUrl passed', () => {
|