unbound-cli 1.1.8 → 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 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
 
@@ -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
@@ -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.
@@ -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.2.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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, checkRoot, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
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 user onboarding: install the default AI tools bundle and run device discovery. ' +
24
- 'Runs `setup --all` followed by `discover` in a single command.'
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>', 'User API key (or set UNBOUND_API_KEY env var, or reuse a stored `unbound login` key)')
27
- .option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY env var)')
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 for an end user:
36
- 1. Logs in with --api-key and stores credentials (or reuses a stored
37
- \`unbound login\` key when --api-key is omitted).
38
- 2. Installs the default tool bundle: ${ALL_TOOLS.join(', ')}.
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 user API key and discovery API key are separate keys obtained from
43
- different parts of the Unbound dashboard. Discovery uses its own key
44
- that is not stored in the CLI config.
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
- // A stored `unbound login` key is enough — only demand --api-key when not
61
- // already logged in. ensureLoggedIn() reuses the stored credential below.
62
- if (!apiKeyOpt && !config.isLoggedIn()) {
63
- output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
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
- if (!discoveryKeyOpt) {
68
- output.error('--discovery-key is required (or set UNBOUND_DISCOVERY_KEY env var)');
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
- await ensureLoggedIn({
96
- apiKey: apiKeyOpt,
97
- baseUrl: written.base_url,
98
- frontendUrl: written.frontend_url,
99
- });
100
- const apiKey = config.getApiKey();
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
- console.log('');
103
- output.info('Step 1/2: Installing tool bundle');
104
- const { ok, skipped } = await runSetupAllBundle(apiKey, {
105
- backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
106
- });
107
- if (!ok) return;
108
- setupSucceeded = true;
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
- console.log('');
111
- output.info('Step 2/2: Running device discovery');
112
- console.log('');
113
- await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
114
- discoverySucceeded = true;
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
- await setupScheduledRun({
121
- command: 'onboard',
122
- apiKey: apiKeyOpt || apiKey,
123
- discoveryKey: discoveryKeyOpt,
124
- domain: discoveryDomain,
125
- skipRunAtLoad: true,
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(skipped && skipped.length
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. Don't tell the user to re-run discovery they already
138
- // did. Tell them how to retry just the cron setup. discover schedule
139
- // would install a discover-only cron, dropping the tool-bundle
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
- console.error(` Re-run cron setup with: unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
145
- } else if (setupSucceeded && !discoverySucceeded) {
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 discovery only with: ${retryCmd}`);
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,94 +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
- frontend: opts.frontendUrl,
225
- gateway: opts.gatewayUrl,
226
- });
227
- const backendUrl = written.base_url || config.getBaseUrl();
228
- const frontendUrl = written.frontend_url || config.getFrontendUrl();
229
- const gatewayUrl = written.gateway_url || config.getGatewayUrl();
230
- discoveryDomain = opts.domain || backendUrl;
231
-
232
- checkRoot('onboard-mdm');
233
-
234
- // Reuse the key stored by `unbound login` when --admin-api-key is omitted.
235
- const adminApiKey = opts.adminApiKey || config.getApiKey();
236
- if (!adminApiKey) {
237
- output.error('--admin-api-key is required (or run `unbound login` first).');
238
- process.exitCode = 1;
239
- return;
240
- }
241
-
242
- console.log('');
243
- output.info('Step 1/2: Installing MDM tool bundle');
244
- const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
245
- backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
246
- });
247
- if (!ok) return;
248
- setupSucceeded = true;
249
-
250
- console.log('');
251
- output.info('Step 2/2: Running device discovery');
252
- console.log('');
253
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
254
-
255
- console.log('');
256
- output.success(skipped && skipped.length
257
- ? 'MDM onboarding complete — tools managed by MDM were skipped (see above)'
258
- : 'MDM onboarding complete');
259
- } catch (err) {
260
- if (!err.displayed) output.error(err.message);
261
- if (setupSucceeded) {
262
- const suffix = domainHintSuffix(discoveryDomain);
263
- console.error(' MDM tool setup completed successfully — only discovery failed.');
264
- console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
265
- }
266
- process.exitCode = 1;
267
- }
268
- });
269
220
  }
270
221
 
271
222
  module.exports = { register };
@@ -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-mdm` (subscription mode for Claude Code/Codex since only one can be active)
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 mdm --all` — identical to MDM_ALL_TOOLS today; split kept for future flexibility.
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
- * `setup mdm --all` split.
76
+ * MDM `--all` split.
77
77
  */
78
78
  function resolveSetupAllTools(clear) {
79
79
  return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
@@ -338,18 +338,6 @@ function runScriptPiped(scriptPath, args) {
338
338
  });
339
339
  }
340
340
 
341
- /**
342
- * Checks that the process is running as root (macOS/Linux).
343
- * Windows admin check is handled by the Python MDM scripts themselves.
344
- * Pass a customized hint for the calling command (defaults to "setup mdm").
345
- */
346
- function checkRoot(commandHint = 'setup mdm') {
347
- if (process.platform === 'win32') return;
348
- if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
349
- throw new Error(`MDM setup requires root. Run with: sudo unbound ${commandHint} ...`);
350
- }
351
- }
352
-
353
341
  /**
354
342
  * Returns true when the process has the privileges needed to touch system-level
355
343
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -455,9 +443,12 @@ function register(program) {
455
443
  .argument('[tools...]', 'Tools to set up')
456
444
  .description(
457
445
  'Configure AI coding tools to use Unbound as their API gateway. ' +
458
- '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.'
459
449
  )
460
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())
461
452
  .option('--clear', 'Remove Unbound configuration for the specified tools (no login or API key required)')
462
453
  .option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
463
454
  .option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
@@ -467,6 +458,10 @@ function register(program) {
467
458
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
468
459
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
469
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
+
470
465
  Available tools:
471
466
  cursor Cursor IDE
472
467
  copilot GitHub Copilot
@@ -496,6 +491,11 @@ Examples:
496
491
  $ unbound setup --all Set up the default bundle
497
492
  $ unbound setup --all --api-key <key> Login + set up the bundle
498
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
+
499
499
  Seed historical sessions (Claude Code / Codex subscription mode + Copilot):
500
500
  $ unbound setup claude-code --subscription --backfill Install hooks AND backfill local history
501
501
  $ unbound setup codex --subscription --backfill Install hooks AND backfill local history
@@ -545,6 +545,95 @@ must update the MDM configuration.
545
545
  const frontendUrl = written.frontend_url || config.getFrontendUrl();
546
546
  const gatewayUrl = written.gateway_url || config.getGatewayUrl();
547
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
+
548
637
  // Clearing config needs no credentials — the setup scripts remove
549
638
  // files without calling the API — so don't force a login for --clear.
550
639
  if (!opts.clear) {
@@ -747,169 +836,6 @@ must update the MDM configuration.
747
836
  }
748
837
  });
749
838
 
750
- // --- MDM setup ---
751
-
752
- // Bare claude-code/codex are accepted too: with --clear they remove both
753
- // modes; for setup they default to subscription (MDM has no mode prompt).
754
- const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
755
-
756
- setup
757
- .command('mdm')
758
- .description(
759
- 'MDM setup: configure all users on this device. Requires root. ' +
760
- 'Used by organization admins to enroll devices via MDM.'
761
- )
762
- .argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
763
- .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key; not required with --clear)')
764
- .option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
765
- .option('--all', 'Set up all available tools')
766
- .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
767
- .addHelpText('after', `
768
- Available tools:
769
- cursor Cursor IDE
770
- copilot GitHub Copilot
771
- claude-code-subscription Claude Code with your own subscription (hooks only)
772
- claude-code-gateway Claude Code with Unbound as AI provider
773
- claude-code Both Claude Code modes (clears both; sets up subscription)
774
- gemini-cli Gemini CLI
775
- codex-subscription Codex with your own subscription (hooks only)
776
- codex-gateway Codex with Unbound as AI provider
777
- codex Both Codex modes (clears both; sets up subscription)
778
-
779
- Note: claude-code-subscription and claude-code-gateway are mutually exclusive when
780
- setting up; same for codex. Bare claude-code/codex set up subscription mode.
781
- When using --all, subscription mode is used by default for Claude Code and Codex.
782
-
783
- Setup examples (need an admin key — pass --admin-api-key, or omit it to reuse the
784
- key stored by a prior \`unbound login\`):
785
- $ sudo unbound setup mdm --admin-api-key KEY cursor
786
- $ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
787
- $ sudo unbound setup mdm --admin-api-key KEY --all
788
- $ sudo unbound setup mdm --all Reuse the stored \`unbound login\` key
789
- $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
790
- Install hooks AND backfill local history
791
- $ sudo unbound setup mdm --admin-api-key KEY copilot --backfill
792
- Install Copilot hooks AND backfill local history
793
-
794
- Clear examples (no API key required):
795
- $ sudo unbound setup mdm --clear cursor
796
- $ sudo unbound setup mdm --clear claude-code Clears BOTH Claude Code modes
797
- $ sudo unbound setup mdm --clear codex Clears BOTH Codex modes
798
- $ sudo unbound setup mdm --clear --all Clears every tool
799
- `)
800
- .action(async (tools, opts, command) => {
801
- try {
802
- checkRoot();
803
- // --all and --clear are defined on both this command and the parent `setup` command;
804
- // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
805
- // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
806
- const globalOpts = command.optsWithGlobals();
807
- // Clearing removes config without calling the API, so a key is only
808
- // required when actually enrolling tools. Fall back to the API key
809
- // stored by `unbound login` so admins who are already logged in don't
810
- // have to pass --admin-api-key again.
811
- const adminApiKey = opts.adminApiKey || config.getApiKey();
812
- if (!globalOpts.clear && !adminApiKey) {
813
- output.error('--admin-api-key is required to set up tools (or run `unbound login` first).');
814
- process.exitCode = 1;
815
- return;
816
- }
817
- // Persist URLs first so this MDM run wires tools at the new tenant
818
- // and any subsequent non-MDM command on the same machine inherits.
819
- // Prefer just-persisted values over env-var-aware getters so a stale
820
- // UNBOUND_*_URL can't shadow the explicit --*-url flag.
821
- const written = config.setUrls({
822
- backend: globalOpts.backendUrl,
823
- frontend: globalOpts.frontendUrl,
824
- gateway: globalOpts.gatewayUrl,
825
- });
826
- const backendUrl = written.base_url || config.getBaseUrl();
827
- const frontendUrl = written.frontend_url || config.getFrontendUrl();
828
- const gatewayUrl = written.gateway_url || config.getGatewayUrl();
829
-
830
- if (globalOpts.all && tools.length > 0) {
831
- output.error('Cannot combine --all with specific tool names. Use one or the other.');
832
- process.exitCode = 1;
833
- return;
834
- }
835
-
836
- let toolNames;
837
- if (globalOpts.all) {
838
- // --clear --all wipes every tool, including both Claude Code/Codex modes.
839
- // Setup --all uses the subscription-default bundle (can't enroll both modes).
840
- toolNames = globalOpts.clear ? Object.keys(MDM_TOOLS) : MDM_SETUP_ALL_TOOLS;
841
- } else if (tools.length > 0) {
842
- toolNames = tools;
843
- } else {
844
- output.error('Specify tools to set up, or use --all.');
845
- console.error(' Available: ' + mdmToolNames);
846
- process.exitCode = 1;
847
- return;
848
- }
849
-
850
- // Expand bare claude-code/codex (MDM has no interactive mode prompt):
851
- // --clear removes both modes; setup defaults to subscription, matching --all.
852
- toolNames = [...new Set(toolNames.flatMap(name => {
853
- const mode = MODE_TOOLS[name];
854
- if (!mode) return [name];
855
- return globalOpts.clear ? [mode.subscription, mode.gateway] : [mode.subscription];
856
- }))];
857
-
858
- const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
859
- if (invalid.length > 0) {
860
- output.error(`Unknown tool(s): ${invalid.join(', ')}`);
861
- console.error(' Available: ' + mdmToolNames);
862
- process.exitCode = 1;
863
- return;
864
- }
865
-
866
- // Mode mutual-exclusivity only applies when setting up — clearing both
867
- // modes at once is valid (and is what bare claude-code/codex --clear does).
868
- if (!globalOpts.clear) {
869
- if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
870
- output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
871
- process.exitCode = 1;
872
- return;
873
- }
874
-
875
- if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
876
- output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
877
- process.exitCode = 1;
878
- return;
879
- }
880
- }
881
-
882
- const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
883
- console.log('');
884
-
885
- if (globalOpts.backfill) {
886
- for (const tool of resolvedTools) {
887
- if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
888
- }
889
- }
890
-
891
- const { ok } = await runBatch(
892
- resolvedTools,
893
- (tool) => {
894
- const toolArgs = buildScriptArgs(adminApiKey, {
895
- backendUrl,
896
- frontendUrl,
897
- gatewayUrl,
898
- clear: globalOpts.clear,
899
- mdm: true,
900
- backfill: globalOpts.backfill && scriptSupportsBackfill(tool.script),
901
- });
902
- return runScriptPiped(tool.script, toolArgs);
903
- },
904
- { clear: globalOpts.clear, summary: globalOpts.clear ? 'All tools cleared' : 'All tools configured' }
905
- );
906
- if (!ok) return;
907
- } catch (err) {
908
- output.error(err.message);
909
- process.exitCode = 1;
910
- }
911
- });
912
-
913
839
  // --- Full uninstall ---
914
840
 
915
841
  program
@@ -1054,7 +980,7 @@ module.exports = {
1054
980
  register,
1055
981
  runSetupAllBundle,
1056
982
  runMdmSetupAllBundle,
1057
- checkRoot,
983
+ hasRootPrivileges,
1058
984
  ALL_TOOLS,
1059
985
  MDM_ALL_TOOLS,
1060
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-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
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 (admin, requires root)
89
- $ sudo unbound setup mdm --admin-api-key KEY --all
90
- $ sudo unbound setup mdm --admin-api-key KEY cursor copilot codex-subscription
91
- $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription codex-subscription gemini-cli
92
- $ sudo unbound setup mdm --clear cursor codex-subscription (no API key needed to clear)
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
+ });