unbound-cli 0.9.6 → 0.9.8

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.6",
3
+ "version": "0.9.8",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -183,6 +183,21 @@ async function runDiscoveryScan({ apiKey, domain }) {
183
183
  await runDiscoveryScript('install.sh', args);
184
184
  }
185
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
+
186
201
  function register(program) {
187
202
  const discover = program
188
203
  .command('discover')
@@ -266,9 +281,7 @@ Examples:
266
281
  return;
267
282
  }
268
283
 
269
- const domain = opts.domain || config.getBaseUrl();
270
- const args = `--api-key ${shellEscape(opts.apiKey)} --domain ${shellEscape(domain)}`;
271
- await runDiscoveryScript('setup-scheduled-scan.sh', args);
284
+ await runDiscoverySchedule({ apiKey: opts.apiKey, domain: opts.domain });
272
285
  } catch (err) {
273
286
  output.error(err.message);
274
287
  process.exitCode = 1;
@@ -349,4 +362,4 @@ Examples:
349
362
  });
350
363
  }
351
364
 
352
- module.exports = { register, runDiscoveryScan, classifyDiscoveryExit };
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.
@@ -26,7 +26,8 @@ function register(program) {
26
26
  .requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
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
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
29
+ .option('--backfill', 'Seed historical Claude Code / Codex / Copilot 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
  }
@@ -116,7 +130,7 @@ Examples:
116
130
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
117
131
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
118
132
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
119
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
133
+ .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
120
134
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
121
135
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
122
136
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
@@ -232,11 +232,13 @@ function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, m
232
232
  return args.trim();
233
233
  }
234
234
 
235
- // Backfill only applies to the hooks variants of Claude Code / Codex; gateway
236
- // mode and Cursor have no local transcripts to seed.
235
+ // Backfill only applies to the hooks variants of Claude Code / Codex / Copilot;
236
+ // gateway mode and Cursor have no local transcripts to seed.
237
237
  function scriptSupportsBackfill(scriptPath) {
238
238
  return scriptPath.includes('/hooks/') && (
239
- scriptPath.startsWith('claude-code/') || scriptPath.startsWith('codex/')
239
+ scriptPath.startsWith('claude-code/') ||
240
+ scriptPath.startsWith('codex/') ||
241
+ scriptPath.startsWith('copilot/')
240
242
  );
241
243
  }
242
244
 
@@ -352,7 +354,7 @@ function register(program) {
352
354
  .option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
353
355
  .option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
354
356
  .option('--all', 'Set up the default bundle: Cursor, Copilot, Claude Code (hooks), Codex (hooks)')
355
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor and Copilot unsupported)')
357
+ .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
356
358
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
357
359
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
358
360
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
@@ -386,9 +388,10 @@ Examples:
386
388
  $ unbound setup --all Set up the default bundle
387
389
  $ unbound setup --all --api-key <key> Login + set up the bundle
388
390
 
389
- Seed historical sessions (Claude Code / Codex subscription mode only):
391
+ Seed historical sessions (Claude Code / Codex subscription mode + Copilot):
390
392
  $ unbound setup claude-code --subscription --backfill Install hooks AND backfill local history
391
393
  $ unbound setup codex --subscription --backfill Install hooks AND backfill local history
394
+ $ unbound setup copilot --backfill Install hooks AND backfill local history
392
395
 
393
396
  One-step login and setup:
394
397
  $ unbound setup cursor --api-key <key> Login + set up Cursor
@@ -648,7 +651,7 @@ requires authentication.
648
651
  .option('--admin-api-key <key>', 'Admin API key for MDM enrollment (not required with --clear)')
649
652
  .option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
650
653
  .option('--all', 'Set up all available tools')
651
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor and Copilot unsupported)')
654
+ .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
652
655
  .addHelpText('after', `
653
656
  Available tools:
654
657
  cursor Cursor IDE
@@ -671,6 +674,8 @@ Setup examples (require --admin-api-key):
671
674
  $ sudo unbound setup mdm --admin-api-key KEY --all
672
675
  $ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
673
676
  Install hooks AND backfill local history
677
+ $ sudo unbound setup mdm --admin-api-key KEY copilot --backfill
678
+ Install Copilot hooks AND backfill local history
674
679
 
675
680
  Clear examples (no API key required):
676
681
  $ sudo unbound setup mdm --clear cursor
@@ -802,9 +807,9 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
802
807
  }
803
808
  }
804
809
  // Build args per-tool so --backfill only goes to tools whose script
805
- // actually supports it (Claude Code hooks and Codex hooks). Cursor would
806
- // print "not supported"; passing the flag to gateway-mode scripts would
807
- // error out — `scriptSupportsBackfill` checks for both.
810
+ // actually supports it (Claude Code hooks, Codex hooks, Copilot hooks).
811
+ // Cursor would print "not supported"; passing the flag to gateway-mode
812
+ // scripts would error out — `scriptSupportsBackfill` checks for both.
808
813
  return runBatch(resolvedTools, (tool) => {
809
814
  const args = buildScriptArgs(apiKey, {
810
815
  backendUrl, frontendUrl, gatewayUrl,
@@ -843,4 +848,5 @@ module.exports = {
843
848
  ALL_TOOLS,
844
849
  MDM_ALL_TOOLS,
845
850
  buildScriptArgs,
851
+ scriptSupportsBackfill,
846
852
  };
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
- const { buildScriptArgs } = require('../src/commands/setup');
3
+ const { buildScriptArgs, scriptSupportsBackfill } = require('../src/commands/setup');
4
4
 
5
5
  // shellEscape single-quotes every value, so a real key surfaces as
6
6
  // --api-key '<key>' at the head of the argv tail.
@@ -51,6 +51,25 @@ test('buildScriptArgs: backfill:true appends --backfill', () => {
51
51
  assert.ok(args.includes('--backfill'), args);
52
52
  });
53
53
 
54
+ // Backfill is supported only by the hooks variants of Claude Code, Codex, and
55
+ // Copilot. Gateway-mode scripts and Cursor have no local transcripts to seed.
56
+ test('scriptSupportsBackfill: hooks variants of claude-code, codex, copilot are supported', () => {
57
+ assert.ok(scriptSupportsBackfill('claude-code/hooks/setup.py'));
58
+ assert.ok(scriptSupportsBackfill('claude-code/hooks/mdm/setup.py'));
59
+ assert.ok(scriptSupportsBackfill('codex/hooks/setup.py'));
60
+ assert.ok(scriptSupportsBackfill('codex/hooks/mdm/setup.py'));
61
+ assert.ok(scriptSupportsBackfill('copilot/hooks/setup.py'));
62
+ assert.ok(scriptSupportsBackfill('copilot/hooks/mdm/setup.py'));
63
+ });
64
+
65
+ test('scriptSupportsBackfill: cursor and gateway-mode scripts are unsupported', () => {
66
+ assert.ok(!scriptSupportsBackfill('cursor/setup.py'));
67
+ assert.ok(!scriptSupportsBackfill('cursor/mdm/setup.py'));
68
+ assert.ok(!scriptSupportsBackfill('claude-code/gateway/setup.py'));
69
+ assert.ok(!scriptSupportsBackfill('codex/gateway/setup.py'));
70
+ assert.ok(!scriptSupportsBackfill('gemini-cli/gateway/setup.py'));
71
+ });
72
+
54
73
  // MDM scripts have no browser-auth flow, so --domain (frontend URL) is
55
74
  // suppressed even when a frontendUrl is supplied.
56
75
  test('buildScriptArgs: mdm:true suppresses --domain even with frontendUrl', () => {