unbound-cli 0.9.3 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/discover.js +45 -9
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +103 -61
- package/test/discover-exit-code.test.js +21 -0
- package/test/onboard-cron.test.js +122 -0
- package/test/setup-args.test.js +85 -0
package/package.json
CHANGED
package/src/commands/discover.js
CHANGED
|
@@ -9,6 +9,18 @@ const output = require('../output');
|
|
|
9
9
|
const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
|
|
10
10
|
const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
|
|
11
11
|
|
|
12
|
+
// install.sh exits with this code when the OS isn't supported for discovery
|
|
13
|
+
// (e.g. Linux). Treated as a skipped scan, not a failure.
|
|
14
|
+
const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
|
|
15
|
+
|
|
16
|
+
// Classifies a discovery subprocess exit code:
|
|
17
|
+
// 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
|
|
18
|
+
function classifyDiscoveryExit(code) {
|
|
19
|
+
if (code === 0) return 'success';
|
|
20
|
+
if (code === DISCOVERY_EXIT_UNSUPPORTED_OS) return 'unsupported';
|
|
21
|
+
return 'failure';
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
// Native Windows (cmd/PowerShell) takes the install.ps1 path below. WSL reports
|
|
13
25
|
// as Linux via process.platform and keeps using the existing bash install.sh pipe.
|
|
14
26
|
function isWindowsNative() {
|
|
@@ -71,11 +83,15 @@ function runDiscoveryScript(scriptName, args) {
|
|
|
71
83
|
const child = spawn(cmd, { shell: true, stdio: 'inherit' });
|
|
72
84
|
|
|
73
85
|
child.on('close', (code) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} else {
|
|
86
|
+
const result = classifyDiscoveryExit(code);
|
|
87
|
+
if (result === 'failure') {
|
|
77
88
|
reject(new Error(`Discovery script failed with exit code ${code}`));
|
|
89
|
+
return;
|
|
78
90
|
}
|
|
91
|
+
if (result === 'unsupported') {
|
|
92
|
+
output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
|
|
93
|
+
}
|
|
94
|
+
resolve();
|
|
79
95
|
});
|
|
80
96
|
|
|
81
97
|
child.on('error', reject);
|
|
@@ -129,8 +145,15 @@ async function runDiscoveryScriptWindows(scriptName, args) {
|
|
|
129
145
|
{ stdio: 'inherit', shell: false, windowsHide: true }
|
|
130
146
|
);
|
|
131
147
|
child.on('close', (code) => {
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
const result = classifyDiscoveryExit(code);
|
|
149
|
+
if (result === 'failure') {
|
|
150
|
+
reject(new Error(`Discovery script failed with exit code ${code}`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (result === 'unsupported') {
|
|
154
|
+
output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
|
|
155
|
+
}
|
|
156
|
+
resolve();
|
|
134
157
|
});
|
|
135
158
|
child.on('error', reject);
|
|
136
159
|
});
|
|
@@ -160,6 +183,21 @@ async function runDiscoveryScan({ apiKey, domain }) {
|
|
|
160
183
|
await runDiscoveryScript('install.sh', args);
|
|
161
184
|
}
|
|
162
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Installs the recurring 12-hour discovery LaunchAgent (macOS only) via
|
|
188
|
+
* setup-scheduled-scan.sh. Extracted so `unbound onboard --cron` reuses the
|
|
189
|
+
* exact same scheduling path as `unbound discover schedule`. The scheduled job
|
|
190
|
+
* runs the discovery scan only — it never runs tool setup or --backfill.
|
|
191
|
+
*/
|
|
192
|
+
async function runDiscoverySchedule({ apiKey, domain }) {
|
|
193
|
+
if (!apiKey) {
|
|
194
|
+
throw new Error('Discovery API key is required.');
|
|
195
|
+
}
|
|
196
|
+
const resolvedDomain = domain || config.getBaseUrl();
|
|
197
|
+
const args = `--api-key ${shellEscape(apiKey)} --domain ${shellEscape(resolvedDomain)}`;
|
|
198
|
+
await runDiscoveryScript('setup-scheduled-scan.sh', args);
|
|
199
|
+
}
|
|
200
|
+
|
|
163
201
|
function register(program) {
|
|
164
202
|
const discover = program
|
|
165
203
|
.command('discover')
|
|
@@ -243,9 +281,7 @@ Examples:
|
|
|
243
281
|
return;
|
|
244
282
|
}
|
|
245
283
|
|
|
246
|
-
|
|
247
|
-
const args = `--api-key ${shellEscape(opts.apiKey)} --domain ${shellEscape(domain)}`;
|
|
248
|
-
await runDiscoveryScript('setup-scheduled-scan.sh', args);
|
|
284
|
+
await runDiscoverySchedule({ apiKey: opts.apiKey, domain: opts.domain });
|
|
249
285
|
} catch (err) {
|
|
250
286
|
output.error(err.message);
|
|
251
287
|
process.exitCode = 1;
|
|
@@ -326,4 +362,4 @@ Examples:
|
|
|
326
362
|
});
|
|
327
363
|
}
|
|
328
364
|
|
|
329
|
-
module.exports = { register, runDiscoveryScan };
|
|
365
|
+
module.exports = { register, runDiscoveryScan, runDiscoverySchedule, classifyDiscoveryExit };
|
package/src/commands/onboard.js
CHANGED
|
@@ -3,7 +3,7 @@ const config = require('../config');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { ensureLoggedIn } = require('../auth');
|
|
5
5
|
const { runSetupAllBundle, runMdmSetupAllBundle, checkRoot, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
6
|
-
const { runDiscoveryScan } = require('./discover');
|
|
6
|
+
const { runDiscoveryScan, runDiscoverySchedule } = require('./discover');
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Builds the recovery-command suffix for partial-failure hints.
|
|
@@ -27,6 +27,7 @@ function register(program) {
|
|
|
27
27
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
28
28
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
29
29
|
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
30
|
+
.option('--cron', 'Set up a recurring 12-hour discovery scan instead of a one-time scan (macOS only)')
|
|
30
31
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
31
32
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
32
33
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
@@ -34,7 +35,8 @@ function register(program) {
|
|
|
34
35
|
Runs the full onboarding flow for an end user:
|
|
35
36
|
1. Logs in with --api-key and stores credentials.
|
|
36
37
|
2. Installs the default tool bundle: ${ALL_TOOLS.join(', ')}.
|
|
37
|
-
3. Runs device discovery with --discovery-key.
|
|
38
|
+
3. Runs device discovery with --discovery-key. With --cron, sets up a
|
|
39
|
+
recurring 12-hour discovery scan (macOS only) instead of a one-time scan.
|
|
38
40
|
|
|
39
41
|
The user API key and discovery API key are separate keys obtained from
|
|
40
42
|
different parts of the Unbound dashboard. Discovery uses its own key
|
|
@@ -48,6 +50,7 @@ For admin device enrollment via MDM, use \`unbound onboard-mdm\` instead.
|
|
|
48
50
|
Examples:
|
|
49
51
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
50
52
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
53
|
+
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --cron
|
|
51
54
|
$ sudo unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
52
55
|
`)
|
|
53
56
|
.action(async (opts) => {
|
|
@@ -90,7 +93,13 @@ Examples:
|
|
|
90
93
|
console.log('');
|
|
91
94
|
output.info('Step 2/2: Running device discovery');
|
|
92
95
|
console.log('');
|
|
93
|
-
|
|
96
|
+
if (opts.cron && process.platform === 'darwin') {
|
|
97
|
+
// --cron sets up the recurring 12-hour scan, which also scans now.
|
|
98
|
+
await runDiscoverySchedule({ apiKey: opts.discoveryKey, domain: discoveryDomain });
|
|
99
|
+
} else {
|
|
100
|
+
if (opts.cron) output.warn('--cron is macOS-only; running a one-time scan instead.');
|
|
101
|
+
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
|
|
102
|
+
}
|
|
94
103
|
|
|
95
104
|
console.log('');
|
|
96
105
|
output.success('Onboarding complete');
|
|
@@ -98,8 +107,13 @@ Examples:
|
|
|
98
107
|
if (!err.displayed) output.error(err.message);
|
|
99
108
|
if (setupSucceeded) {
|
|
100
109
|
const suffix = domainHintSuffix(discoveryDomain);
|
|
110
|
+
// Point at the path the user actually wanted: the scheduler when
|
|
111
|
+
// --cron was used on macOS, otherwise the one-time scan.
|
|
112
|
+
const retryCmd = opts.cron && process.platform === 'darwin'
|
|
113
|
+
? `unbound discover schedule --api-key <DISCOVERY_KEY>${suffix}`
|
|
114
|
+
: `unbound discover --api-key <DISCOVERY_KEY>${suffix}`;
|
|
101
115
|
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
102
|
-
console.error(` Re-run discovery only with:
|
|
116
|
+
console.error(` Re-run discovery only with: ${retryCmd}`);
|
|
103
117
|
}
|
|
104
118
|
process.exitCode = 1;
|
|
105
119
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -37,15 +37,15 @@ const MDM_TOOLS = {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// Default MDM tools for `unbound onboard-mdm` (subscription mode for Claude Code/Codex since only one can be active)
|
|
40
|
-
const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription'];
|
|
40
|
+
const MDM_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
|
|
41
41
|
|
|
42
|
-
// Tools for `unbound setup mdm --all` —
|
|
42
|
+
// Tools for `unbound setup mdm --all` — identical to MDM_ALL_TOOLS today; split kept for future flexibility.
|
|
43
43
|
const MDM_SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot'];
|
|
44
44
|
|
|
45
|
-
// Default tools for `unbound onboard` (Cursor, Claude Code hooks, Codex hooks; no Gemini CLI).
|
|
46
|
-
const ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription'];
|
|
45
|
+
// Default tools for `unbound onboard` (Cursor, Claude Code hooks, Codex hooks, Copilot hooks; no Gemini CLI).
|
|
46
|
+
const ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot'];
|
|
47
47
|
|
|
48
|
-
// Tools for `unbound setup --all` —
|
|
48
|
+
// Tools for `unbound setup --all` — identical to ALL_TOOLS today; split kept for future flexibility.
|
|
49
49
|
const SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot'];
|
|
50
50
|
|
|
51
51
|
// Tool name → script mapping for automated tools
|
|
@@ -221,13 +221,15 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
|
|
|
221
221
|
* have no browser-auth flow.
|
|
222
222
|
*/
|
|
223
223
|
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
|
|
224
|
-
|
|
224
|
+
// --clear runs no auth in the Python scripts, so the key may be absent. Omit
|
|
225
|
+
// the flag entirely rather than passing --api-key 'undefined'.
|
|
226
|
+
let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
|
|
225
227
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
226
228
|
if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
|
|
227
229
|
if (!mdm && frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
228
230
|
if (clear) args += ' --clear';
|
|
229
231
|
if (backfill) args += ' --backfill';
|
|
230
|
-
return args;
|
|
232
|
+
return args.trim();
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
// Backfill only applies to the hooks variants of Claude Code / Codex; gateway
|
|
@@ -346,7 +348,7 @@ function register(program) {
|
|
|
346
348
|
'Run with no arguments for interactive setup, or specify tools directly.'
|
|
347
349
|
)
|
|
348
350
|
.option('--api-key <key>', 'Authenticate with an API key (skips browser login)')
|
|
349
|
-
.option('--clear', 'Remove Unbound configuration for the specified tools')
|
|
351
|
+
.option('--clear', 'Remove Unbound configuration for the specified tools (no login or API key required)')
|
|
350
352
|
.option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
|
|
351
353
|
.option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
|
|
352
354
|
.option('--all', 'Set up the default bundle: Cursor, Copilot, Claude Code (hooks), Codex (hooks)')
|
|
@@ -393,17 +395,19 @@ Examples:
|
|
|
393
395
|
$ unbound setup cursor claude-code-gateway --api-key <key>
|
|
394
396
|
Login + set up multiple tools
|
|
395
397
|
|
|
396
|
-
Remove configuration:
|
|
398
|
+
Remove configuration (no login or API key required):
|
|
397
399
|
$ unbound setup cursor --clear Remove Cursor config
|
|
398
400
|
$ unbound setup copilot --clear Remove GitHub Copilot config
|
|
399
|
-
$ unbound setup claude-code --clear Remove Claude Code
|
|
401
|
+
$ unbound setup claude-code --clear Remove BOTH Claude Code modes (subscription + gateway)
|
|
402
|
+
$ unbound setup codex --clear Remove BOTH Codex modes (subscription + gateway)
|
|
400
403
|
|
|
401
404
|
Interactive:
|
|
402
405
|
$ unbound setup Select tools interactively
|
|
403
406
|
$ unbound setup --api-key <key> Login, then select interactively
|
|
404
407
|
|
|
405
|
-
|
|
406
|
-
automatically to authenticate
|
|
408
|
+
When setting up, if you are not logged in and --api-key is not provided, the
|
|
409
|
+
browser opens automatically to authenticate first. Clearing (--clear) never
|
|
410
|
+
requires authentication.
|
|
407
411
|
`)
|
|
408
412
|
.action(async (tools, opts) => {
|
|
409
413
|
try {
|
|
@@ -421,11 +425,15 @@ automatically to authenticate before proceeding.
|
|
|
421
425
|
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
422
426
|
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
423
427
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
428
|
+
// Clearing config needs no credentials — the setup scripts remove
|
|
429
|
+
// files without calling the API — so don't force a login for --clear.
|
|
430
|
+
if (!opts.clear) {
|
|
431
|
+
await ensureLoggedIn({
|
|
432
|
+
apiKey: opts.apiKey,
|
|
433
|
+
baseUrl: written.base_url,
|
|
434
|
+
frontendUrl: written.frontend_url,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
429
437
|
const apiKey = config.getApiKey();
|
|
430
438
|
const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
|
|
431
439
|
|
|
@@ -493,30 +501,33 @@ automatically to authenticate before proceeding.
|
|
|
493
501
|
return;
|
|
494
502
|
}
|
|
495
503
|
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
504
|
+
// Mode mutual-exclusivity only applies when setting up — clearing both
|
|
505
|
+
// modes at once is valid (and is what bare claude-code/codex --clear does).
|
|
506
|
+
if (!opts.clear) {
|
|
507
|
+
if (tools.includes('claude-code-subscription') && tools.includes('claude-code-gateway')) {
|
|
508
|
+
output.error('Cannot set up both claude-code-subscription and claude-code-gateway. Choose one.');
|
|
509
|
+
process.exitCode = 1;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (tools.includes('codex-subscription') && tools.includes('codex-gateway')) {
|
|
513
|
+
output.error('Cannot set up both codex-subscription and codex-gateway. Choose one.');
|
|
514
|
+
process.exitCode = 1;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
507
517
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
518
|
+
// Validate no bare + suffixed name conflicts
|
|
519
|
+
if (tools.includes('claude-code') && (tools.includes('claude-code-subscription') || tools.includes('claude-code-gateway'))) {
|
|
520
|
+
output.error('Cannot combine claude-code with claude-code-subscription or claude-code-gateway.');
|
|
521
|
+
console.error(' Use --subscription or --gateway with claude-code, or use the explicit name directly.');
|
|
522
|
+
process.exitCode = 1;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (tools.includes('codex') && (tools.includes('codex-subscription') || tools.includes('codex-gateway'))) {
|
|
526
|
+
output.error('Cannot combine codex with codex-subscription or codex-gateway.');
|
|
527
|
+
console.error(' Use --subscription or --gateway with codex, or use the explicit name directly.');
|
|
528
|
+
process.exitCode = 1;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
520
531
|
}
|
|
521
532
|
|
|
522
533
|
// Validate --subscription/--gateway only with tools that need them
|
|
@@ -623,7 +634,9 @@ automatically to authenticate before proceeding.
|
|
|
623
634
|
|
|
624
635
|
// --- MDM setup ---
|
|
625
636
|
|
|
626
|
-
|
|
637
|
+
// Bare claude-code/codex are accepted too: with --clear they remove both
|
|
638
|
+
// modes; for setup they default to subscription (MDM has no mode prompt).
|
|
639
|
+
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
627
640
|
|
|
628
641
|
setup
|
|
629
642
|
.command('mdm')
|
|
@@ -632,31 +645,38 @@ automatically to authenticate before proceeding.
|
|
|
632
645
|
'Used by organization admins to enroll devices via MDM.'
|
|
633
646
|
)
|
|
634
647
|
.argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
|
|
635
|
-
.
|
|
636
|
-
.option('--clear', 'Remove Unbound configuration for the specified tools')
|
|
648
|
+
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (not required with --clear)')
|
|
649
|
+
.option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
|
|
637
650
|
.option('--all', 'Set up all available tools')
|
|
638
|
-
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
|
|
651
|
+
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor and Copilot unsupported)')
|
|
639
652
|
.addHelpText('after', `
|
|
640
653
|
Available tools:
|
|
641
654
|
cursor Cursor IDE
|
|
642
655
|
copilot GitHub Copilot
|
|
643
656
|
claude-code-subscription Claude Code with your own subscription (hooks only)
|
|
644
657
|
claude-code-gateway Claude Code with Unbound as AI provider
|
|
658
|
+
claude-code Both Claude Code modes (clears both; sets up subscription)
|
|
645
659
|
gemini-cli Gemini CLI
|
|
646
660
|
codex-subscription Codex with your own subscription (hooks only)
|
|
647
661
|
codex-gateway Codex with Unbound as AI provider
|
|
662
|
+
codex Both Codex modes (clears both; sets up subscription)
|
|
648
663
|
|
|
649
|
-
Note: claude-code-subscription and claude-code-gateway are mutually exclusive
|
|
650
|
-
codex
|
|
651
|
-
When using --all, subscription mode is used by default for Claude Code and Codex.
|
|
664
|
+
Note: claude-code-subscription and claude-code-gateway are mutually exclusive when
|
|
665
|
+
setting up; same for codex. Bare claude-code/codex set up subscription mode.
|
|
666
|
+
When using --all, subscription mode is used by default for Claude Code and Codex.
|
|
652
667
|
|
|
653
|
-
|
|
668
|
+
Setup examples (require --admin-api-key):
|
|
654
669
|
$ sudo unbound setup mdm --admin-api-key KEY cursor
|
|
655
670
|
$ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
|
|
656
671
|
$ sudo unbound setup mdm --admin-api-key KEY --all
|
|
657
|
-
$ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
|
|
658
672
|
$ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
|
|
659
673
|
Install hooks AND backfill local history
|
|
674
|
+
|
|
675
|
+
Clear examples (no API key required):
|
|
676
|
+
$ sudo unbound setup mdm --clear cursor
|
|
677
|
+
$ sudo unbound setup mdm --clear claude-code Clears BOTH Claude Code modes
|
|
678
|
+
$ sudo unbound setup mdm --clear codex Clears BOTH Codex modes
|
|
679
|
+
$ sudo unbound setup mdm --clear --all Clears every tool
|
|
660
680
|
`)
|
|
661
681
|
.action(async (tools, opts, command) => {
|
|
662
682
|
try {
|
|
@@ -665,6 +685,13 @@ Examples:
|
|
|
665
685
|
// --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
|
|
666
686
|
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
667
687
|
const globalOpts = command.optsWithGlobals();
|
|
688
|
+
// Clearing removes config without calling the API, so a key is only
|
|
689
|
+
// required when actually enrolling tools.
|
|
690
|
+
if (!globalOpts.clear && !opts.adminApiKey) {
|
|
691
|
+
output.error('--admin-api-key is required to set up tools.');
|
|
692
|
+
process.exitCode = 1;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
668
695
|
// Persist URLs first so this MDM run wires tools at the new tenant
|
|
669
696
|
// and any subsequent non-MDM command on the same machine inherits.
|
|
670
697
|
// Prefer just-persisted values over env-var-aware getters so a stale
|
|
@@ -684,7 +711,9 @@ Examples:
|
|
|
684
711
|
|
|
685
712
|
let toolNames;
|
|
686
713
|
if (globalOpts.all) {
|
|
687
|
-
|
|
714
|
+
// --clear --all wipes every tool, including both Claude Code/Codex modes.
|
|
715
|
+
// Setup --all uses the subscription-default bundle (can't enroll both modes).
|
|
716
|
+
toolNames = globalOpts.clear ? Object.keys(MDM_TOOLS) : MDM_SETUP_ALL_TOOLS;
|
|
688
717
|
} else if (tools.length > 0) {
|
|
689
718
|
toolNames = tools;
|
|
690
719
|
} else {
|
|
@@ -694,6 +723,14 @@ Examples:
|
|
|
694
723
|
return;
|
|
695
724
|
}
|
|
696
725
|
|
|
726
|
+
// Expand bare claude-code/codex (MDM has no interactive mode prompt):
|
|
727
|
+
// --clear removes both modes; setup defaults to subscription, matching --all.
|
|
728
|
+
toolNames = [...new Set(toolNames.flatMap(name => {
|
|
729
|
+
const mode = MODE_TOOLS[name];
|
|
730
|
+
if (!mode) return [name];
|
|
731
|
+
return globalOpts.clear ? [mode.subscription, mode.gateway] : [mode.subscription];
|
|
732
|
+
}))];
|
|
733
|
+
|
|
697
734
|
const invalid = toolNames.filter(t => !MDM_TOOLS[t]);
|
|
698
735
|
if (invalid.length > 0) {
|
|
699
736
|
output.error(`Unknown tool(s): ${invalid.join(', ')}`);
|
|
@@ -702,16 +739,20 @@ Examples:
|
|
|
702
739
|
return;
|
|
703
740
|
}
|
|
704
741
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
742
|
+
// Mode mutual-exclusivity only applies when setting up — clearing both
|
|
743
|
+
// modes at once is valid (and is what bare claude-code/codex --clear does).
|
|
744
|
+
if (!globalOpts.clear) {
|
|
745
|
+
if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
|
|
746
|
+
output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
|
|
747
|
+
process.exitCode = 1;
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
710
750
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
751
|
+
if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
|
|
752
|
+
output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
|
|
753
|
+
process.exitCode = 1;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
715
756
|
}
|
|
716
757
|
|
|
717
758
|
const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
@@ -749,7 +790,7 @@ Examples:
|
|
|
749
790
|
}
|
|
750
791
|
|
|
751
792
|
/**
|
|
752
|
-
* Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks) with spinners.
|
|
793
|
+
* Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners.
|
|
753
794
|
* Assumes the caller has already ensured the user is logged in.
|
|
754
795
|
* Returns true on success, false on failure.
|
|
755
796
|
*/
|
|
@@ -774,7 +815,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
774
815
|
}
|
|
775
816
|
|
|
776
817
|
/**
|
|
777
|
-
* Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks) with spinners.
|
|
818
|
+
* Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners.
|
|
778
819
|
* Caller must ensure the process is running as root.
|
|
779
820
|
* Returns true on success, false on failure.
|
|
780
821
|
*/
|
|
@@ -801,4 +842,5 @@ module.exports = {
|
|
|
801
842
|
checkRoot,
|
|
802
843
|
ALL_TOOLS,
|
|
803
844
|
MDM_ALL_TOOLS,
|
|
845
|
+
buildScriptArgs,
|
|
804
846
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { classifyDiscoveryExit } = require('../src/commands/discover');
|
|
4
|
+
|
|
5
|
+
test('classifyDiscoveryExit: 0 is a successful scan', () => {
|
|
6
|
+
assert.equal(classifyDiscoveryExit(0), 'success');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('classifyDiscoveryExit: 3 is an unsupported-OS skip, not a failure', () => {
|
|
10
|
+
assert.equal(classifyDiscoveryExit(3), 'unsupported');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('classifyDiscoveryExit: other non-zero codes are failures', () => {
|
|
14
|
+
assert.equal(classifyDiscoveryExit(1), 'failure');
|
|
15
|
+
assert.equal(classifyDiscoveryExit(2), 'failure');
|
|
16
|
+
assert.equal(classifyDiscoveryExit(127), 'failure');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('classifyDiscoveryExit: null (process killed by signal) is a failure', () => {
|
|
20
|
+
assert.equal(classifyDiscoveryExit(null), 'failure');
|
|
21
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const discover = require('../src/commands/discover');
|
|
7
|
+
|
|
8
|
+
const DISCOVER_SRC_PATH = path.join(__dirname, '..', 'src', 'commands', 'discover.js');
|
|
9
|
+
const ONBOARD_SRC_PATH = path.join(__dirname, '..', 'src', 'commands', 'onboard.js');
|
|
10
|
+
|
|
11
|
+
const discoverSrc = fs.readFileSync(DISCOVER_SRC_PATH, 'utf8');
|
|
12
|
+
const onboardSrc = fs.readFileSync(ONBOARD_SRC_PATH, 'utf8');
|
|
13
|
+
|
|
14
|
+
// Extracts the textual body of a top-level `async function <name>(...) { ... }`
|
|
15
|
+
// by brace-matching from the function keyword. Lets us assert on a single
|
|
16
|
+
// function's body without false positives from the rest of the file.
|
|
17
|
+
function extractFunctionBody(src, name) {
|
|
18
|
+
const start = src.indexOf(`async function ${name}`);
|
|
19
|
+
assert.notEqual(start, -1, `expected "async function ${name}" in source`);
|
|
20
|
+
// Skip the parameter list (which may itself contain destructuring braces)
|
|
21
|
+
// by finding the matching close-paren first, then the body's opening brace.
|
|
22
|
+
const paramOpen = src.indexOf('(', start);
|
|
23
|
+
let pDepth = 0;
|
|
24
|
+
let paramClose = -1;
|
|
25
|
+
for (let i = paramOpen; i < src.length; i++) {
|
|
26
|
+
if (src[i] === '(') pDepth++;
|
|
27
|
+
else if (src[i] === ')') {
|
|
28
|
+
pDepth--;
|
|
29
|
+
if (pDepth === 0) { paramClose = i; break; }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
assert.notEqual(paramClose, -1, `unbalanced parens in ${name} signature`);
|
|
33
|
+
const open = src.indexOf('{', paramClose);
|
|
34
|
+
assert.notEqual(open, -1, `expected an opening brace after ${name}`);
|
|
35
|
+
let depth = 0;
|
|
36
|
+
for (let i = open; i < src.length; i++) {
|
|
37
|
+
if (src[i] === '{') depth++;
|
|
38
|
+
else if (src[i] === '}') {
|
|
39
|
+
depth--;
|
|
40
|
+
if (depth === 0) return src.slice(open, i + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`unbalanced braces while extracting ${name}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const scheduleBody = extractFunctionBody(discoverSrc, 'runDiscoverySchedule');
|
|
47
|
+
|
|
48
|
+
test('runDiscoverySchedule is exported as a function', () => {
|
|
49
|
+
assert.equal(typeof discover.runDiscoverySchedule, 'function');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('runDiscoverySchedule takes a single destructured options object', () => {
|
|
53
|
+
// One formal parameter: ({ apiKey, domain }). Arity counts that single object.
|
|
54
|
+
assert.equal(discover.runDiscoverySchedule.length, 1);
|
|
55
|
+
// Accepts exactly apiKey and domain — no third field could carry backfill.
|
|
56
|
+
assert.match(discoverSrc, /async function runDiscoverySchedule\(\{\s*apiKey,\s*domain\s*\}\)/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the scheduled path runs setup-scheduled-scan.sh, never install.sh', () => {
|
|
60
|
+
assert.ok(
|
|
61
|
+
scheduleBody.includes("'setup-scheduled-scan.sh'"),
|
|
62
|
+
'runDiscoverySchedule must schedule setup-scheduled-scan.sh'
|
|
63
|
+
);
|
|
64
|
+
assert.ok(
|
|
65
|
+
!scheduleBody.includes('install.sh'),
|
|
66
|
+
'runDiscoverySchedule must not invoke install.sh'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Core WEB-4499 regression guard: backfill is a one-time setup operation and
|
|
71
|
+
// must be structurally impossible to reach the recurring scheduled scan.
|
|
72
|
+
test('runDiscoverySchedule body contains no backfill reference', () => {
|
|
73
|
+
assert.ok(
|
|
74
|
+
!scheduleBody.toLowerCase().includes('backfill'),
|
|
75
|
+
'backfill must never appear in the scheduled-scan code path'
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('runDiscoverySchedule shell-escapes both apiKey and domain', () => {
|
|
80
|
+
// Both user-influenced values pass through shellEscape before hitting the
|
|
81
|
+
// shell:true spawn, so neither can break out of the command string.
|
|
82
|
+
assert.match(scheduleBody, /--api-key \$\{shellEscape\(apiKey\)\}/);
|
|
83
|
+
assert.match(scheduleBody, /--domain \$\{shellEscape\(resolvedDomain\)\}/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The onboard --cron path must call the shared schedule helper with only
|
|
87
|
+
// apiKey + domain — never forwarding opts.backfill into the schedule.
|
|
88
|
+
test('onboard schedules via runDiscoverySchedule with only apiKey and domain', () => {
|
|
89
|
+
assert.ok(
|
|
90
|
+
onboardSrc.includes('runDiscoverySchedule({ apiKey: opts.discoveryKey, domain: discoveryDomain })'),
|
|
91
|
+
'onboard must call runDiscoverySchedule with exactly { apiKey, domain }'
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('onboard never passes backfill into runDiscoverySchedule', () => {
|
|
96
|
+
// Find the runDiscoverySchedule call site in onboard and confirm its
|
|
97
|
+
// argument object carries no backfill key.
|
|
98
|
+
const callIdx = onboardSrc.indexOf('runDiscoverySchedule(');
|
|
99
|
+
assert.notEqual(callIdx, -1, 'expected a runDiscoverySchedule call in onboard');
|
|
100
|
+
const open = onboardSrc.indexOf('(', callIdx);
|
|
101
|
+
const close = onboardSrc.indexOf(')', open);
|
|
102
|
+
const callArgs = onboardSrc.slice(open, close + 1);
|
|
103
|
+
assert.ok(!callArgs.toLowerCase().includes('backfill'), callArgs);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('runDiscoverySchedule is re-exported into onboard from discover', () => {
|
|
107
|
+
// onboard pulls the helper from ./discover rather than re-implementing it,
|
|
108
|
+
// so both the `discover schedule` command and `onboard --cron` share one path.
|
|
109
|
+
assert.match(
|
|
110
|
+
onboardSrc,
|
|
111
|
+
/const \{[^}]*runDiscoverySchedule[^}]*\} = require\('\.\/discover'\)/
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('runDiscoverySchedule rejects when apiKey is missing', async () => {
|
|
116
|
+
// Mirrors runDiscoveryScan's guard so a future caller can't silently send
|
|
117
|
+
// --api-key 'undefined' to the schedule script. Throws before any network.
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
() => discover.runDiscoverySchedule({ domain: 'https://example.com' }),
|
|
120
|
+
/Discovery API key is required/
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { buildScriptArgs } = require('../src/commands/setup');
|
|
4
|
+
|
|
5
|
+
// shellEscape single-quotes every value, so a real key surfaces as
|
|
6
|
+
// --api-key '<key>' at the head of the argv tail.
|
|
7
|
+
test('buildScriptArgs: real apiKey leads with single-quoted --api-key', () => {
|
|
8
|
+
const args = buildScriptArgs('sk-abc123', {});
|
|
9
|
+
assert.ok(args.startsWith("--api-key 'sk-abc123'"), args);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// WEB-4498 core behavior: --clear runs no auth, so a missing key must NOT
|
|
13
|
+
// produce --api-key and must NOT leak the literal string "undefined".
|
|
14
|
+
test('buildScriptArgs: clear without apiKey omits --api-key and never emits undefined', () => {
|
|
15
|
+
const args = buildScriptArgs(undefined, { clear: true });
|
|
16
|
+
assert.ok(!args.includes('--api-key'), args);
|
|
17
|
+
assert.ok(!args.includes('undefined'), args);
|
|
18
|
+
assert.ok(args.includes('--clear'), args);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Empty-string key is falsy too — same omission rule applies.
|
|
22
|
+
test('buildScriptArgs: clear with empty-string apiKey still omits --api-key', () => {
|
|
23
|
+
const args = buildScriptArgs('', { clear: true });
|
|
24
|
+
assert.ok(!args.includes('--api-key'), args);
|
|
25
|
+
assert.ok(!args.includes('undefined'), args);
|
|
26
|
+
assert.ok(args.includes('--clear'), args);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// With no key, the first appended flag must not leave a leading space —
|
|
30
|
+
// the result is trimmed and carries the URL flags plus --clear.
|
|
31
|
+
test('buildScriptArgs: clear without key + URLs is trimmed and carries url flags', () => {
|
|
32
|
+
const args = buildScriptArgs(undefined, {
|
|
33
|
+
clear: true,
|
|
34
|
+
backendUrl: 'https://backend.acme.com',
|
|
35
|
+
gatewayUrl: 'https://gateway.acme.com',
|
|
36
|
+
});
|
|
37
|
+
assert.equal(args, args.trim());
|
|
38
|
+
assert.ok(!args.startsWith(' '), args);
|
|
39
|
+
assert.ok(args.includes('--backend-url'), args);
|
|
40
|
+
assert.ok(args.includes('--gateway-url'), args);
|
|
41
|
+
assert.ok(args.includes('--clear'), args);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('buildScriptArgs: clear:true appends --clear', () => {
|
|
45
|
+
const args = buildScriptArgs('sk-x', { clear: true });
|
|
46
|
+
assert.ok(args.includes('--clear'), args);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('buildScriptArgs: backfill:true appends --backfill', () => {
|
|
50
|
+
const args = buildScriptArgs('sk-x', { backfill: true });
|
|
51
|
+
assert.ok(args.includes('--backfill'), args);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// MDM scripts have no browser-auth flow, so --domain (frontend URL) is
|
|
55
|
+
// suppressed even when a frontendUrl is supplied.
|
|
56
|
+
test('buildScriptArgs: mdm:true suppresses --domain even with frontendUrl', () => {
|
|
57
|
+
const args = buildScriptArgs('sk-x', {
|
|
58
|
+
mdm: true,
|
|
59
|
+
frontendUrl: 'https://gateway.acme.com',
|
|
60
|
+
});
|
|
61
|
+
assert.ok(!args.includes('--domain'), args);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('buildScriptArgs: non-mdm includes --domain when frontendUrl passed', () => {
|
|
65
|
+
const args = buildScriptArgs('sk-x', {
|
|
66
|
+
mdm: false,
|
|
67
|
+
frontendUrl: 'https://gateway.acme.com',
|
|
68
|
+
});
|
|
69
|
+
assert.ok(args.includes("--domain 'https://gateway.acme.com'"), args);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The result is always trimmed regardless of which flags are present.
|
|
73
|
+
test('buildScriptArgs: result is always trimmed', () => {
|
|
74
|
+
const cases = [
|
|
75
|
+
['sk-x', {}],
|
|
76
|
+
[undefined, { clear: true }],
|
|
77
|
+
['', { clear: true, backendUrl: 'https://b.acme.com' }],
|
|
78
|
+
['sk-x', { backfill: true, mdm: true, frontendUrl: 'https://f.acme.com' }],
|
|
79
|
+
[undefined, {}],
|
|
80
|
+
];
|
|
81
|
+
for (const [key, opts] of cases) {
|
|
82
|
+
const args = buildScriptArgs(key, opts);
|
|
83
|
+
assert.equal(args, args.trim(), JSON.stringify([key, opts]));
|
|
84
|
+
}
|
|
85
|
+
});
|