unbound-cli 0.9.6 → 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.6",
3
+ "version": "0.9.7",
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.
@@ -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
  }
@@ -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
+ });