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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.7",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
- if (code === 0) {
75
- resolve();
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
- if (code === 0) resolve();
133
- else reject(new Error(`Discovery script failed with exit code ${code}`));
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
- const domain = opts.domain || config.getBaseUrl();
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 };
@@ -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
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
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: unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
116
+ console.error(` Re-run discovery only with: ${retryCmd}`);
103
117
  }
104
118
  process.exitCode = 1;
105
119
  }
@@ -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` — same as the onboard-mdm bundle plus Copilot.
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` — same as the onboard bundle plus Copilot.
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
- let args = `--api-key ${shellEscape(apiKey)}`;
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 config
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
- If not logged in and --api-key is not provided, the browser will open
406
- automatically to authenticate before proceeding.
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
- await ensureLoggedIn({
425
- apiKey: opts.apiKey,
426
- baseUrl: written.base_url,
427
- frontendUrl: written.frontend_url,
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
- // Validate mutual exclusivity
497
- if (tools.includes('claude-code-subscription') && tools.includes('claude-code-gateway')) {
498
- output.error('Cannot set up both claude-code-subscription and claude-code-gateway. Choose one.');
499
- process.exitCode = 1;
500
- return;
501
- }
502
- if (tools.includes('codex-subscription') && tools.includes('codex-gateway')) {
503
- output.error('Cannot set up both codex-subscription and codex-gateway. Choose one.');
504
- process.exitCode = 1;
505
- return;
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
- // Validate no bare + suffixed name conflicts
509
- if (tools.includes('claude-code') && (tools.includes('claude-code-subscription') || tools.includes('claude-code-gateway'))) {
510
- output.error('Cannot combine claude-code with claude-code-subscription or claude-code-gateway.');
511
- console.error(' Use --subscription or --gateway with claude-code, or use the explicit name directly.');
512
- process.exitCode = 1;
513
- return;
514
- }
515
- if (tools.includes('codex') && (tools.includes('codex-subscription') || tools.includes('codex-gateway'))) {
516
- output.error('Cannot combine codex with codex-subscription or codex-gateway.');
517
- console.error(' Use --subscription or --gateway with codex, or use the explicit name directly.');
518
- process.exitCode = 1;
519
- return;
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
- const mdmToolNames = Object.keys(MDM_TOOLS).join(', ');
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
- .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
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-subscription and codex-gateway are mutually exclusive.
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
- Examples:
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
- toolNames = MDM_SETUP_ALL_TOOLS;
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
- if (toolNames.includes('claude-code-subscription') && toolNames.includes('claude-code-gateway')) {
706
- output.error('Cannot use both claude-code-subscription and claude-code-gateway. Choose one.');
707
- process.exitCode = 1;
708
- return;
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
- if (toolNames.includes('codex-subscription') && toolNames.includes('codex-gateway')) {
712
- output.error('Cannot use both codex-subscription and codex-gateway. Choose one.');
713
- process.exitCode = 1;
714
- return;
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
+ });