unbound-cli 0.9.7 → 0.9.9

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/README.md CHANGED
@@ -123,7 +123,7 @@ Scan a device for installed AI coding tools and report findings to Unbound. Uses
123
123
  | `sudo unbound discover --api-key KEY` | Scan all users on the device (requires root) |
124
124
  | `unbound discover --api-key KEY` | Scan current user only |
125
125
  | `sudo unbound discover --api-key KEY --domain URL` | Scan with a custom backend URL |
126
- | `unbound discover schedule --api-key KEY` | Set up 12-hour recurring scan (macOS only) |
126
+ | `unbound discover --set-cron --api-key KEY` | Set up daily scan at 09:00 (cross-platform) |
127
127
  | `unbound discover unschedule` | Remove the scheduled scan |
128
128
  | `unbound discover status` | Show scan schedule and log paths |
129
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,11 +2,9 @@ const { spawn, execSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
- const https = require('https');
6
5
  const config = require('../config');
7
6
  const output = require('../output');
8
-
9
- const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
7
+ const { shellEscape, downloadToFile, DISCOVER_BASE_URL } = require('../utils');
10
8
  const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
11
9
 
12
10
  // install.sh exits with this code when the OS isn't supported for discovery
@@ -32,37 +30,6 @@ function isRoot() {
32
30
  return typeof process.getuid === 'function' && process.getuid() === 0;
33
31
  }
34
32
 
35
- function downloadToFile(url, destPath) {
36
- return new Promise((resolve, reject) => {
37
- const request = (u, remaining) => {
38
- https.get(u, (res) => {
39
- if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
40
- res.resume();
41
- return request(res.headers.location, remaining - 1);
42
- }
43
- if (res.statusCode !== 200) {
44
- res.resume();
45
- return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
46
- }
47
- const file = fs.createWriteStream(destPath);
48
- res.pipe(file);
49
- file.on('finish', () => file.close(resolve));
50
- file.on('error', reject);
51
- }).on('error', reject);
52
- };
53
- request(url, 3);
54
- });
55
- }
56
-
57
- /**
58
- * Escapes a string for safe embedding in a shell command (single-quote wrap).
59
- * Mirrors the helper in setup.js. Required because runDiscoveryScript uses
60
- * spawn(cmd, { shell: true }) which routes the full command through bash.
61
- */
62
- function shellEscape(str) {
63
- return "'" + String(str).replace(/'/g, "'\\''") + "'";
64
- }
65
-
66
33
  /**
67
34
  * Downloads a bash script from the discovery repo and executes it with arguments.
68
35
  * Uses stdio: 'inherit' so the script's output is shown live.
@@ -183,20 +150,6 @@ async function runDiscoveryScan({ apiKey, domain }) {
183
150
  await runDiscoveryScript('install.sh', args);
184
151
  }
185
152
 
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
153
 
201
154
  function register(program) {
202
155
  const discover = program
@@ -207,6 +160,7 @@ function register(program) {
207
160
  )
208
161
  .option('--api-key <key>', 'Discovery API key (required)')
209
162
  .option('--domain <url>', 'Backend URL (defaults to configured backend)')
163
+ .option('--set-cron', 'Set up a daily scheduled discovery scan at 09:00 (cross-platform)')
210
164
  .addHelpText('after', `
211
165
  Scans this device for installed AI coding tools (Cursor, Claude Code,
212
166
  Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
@@ -228,6 +182,7 @@ Examples:
228
182
  $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
229
183
  `)
230
184
  .action(async (opts) => {
185
+ let scanSucceeded = false;
231
186
  try {
232
187
  if (!opts.apiKey) {
233
188
  output.error('--api-key is required.');
@@ -236,32 +191,57 @@ Examples:
236
191
  }
237
192
 
238
193
  await runDiscoveryScan({ apiKey: opts.apiKey, domain: opts.domain });
194
+ scanSucceeded = true;
195
+
196
+ if (opts.setCron) {
197
+ console.log('');
198
+ output.info('Setting up daily scheduled discovery scan');
199
+ const { setupScheduledRun } = require('../scheduled');
200
+ await setupScheduledRun({
201
+ command: 'discover',
202
+ apiKey: opts.apiKey,
203
+ domain: opts.domain || config.getBaseUrl(),
204
+ skipRunAtLoad: true,
205
+ });
206
+ }
239
207
 
240
208
  console.log('');
241
209
  output.success('Discovery complete');
242
210
  } catch (err) {
243
211
  output.error(err.message);
212
+ // Hint only when the scan completed and the user actually asked for
213
+ // cron setup — guards against a future code path between scanSucceeded
214
+ // and the if(opts.setCron) block throwing and showing a misleading hint.
215
+ if (scanSucceeded && opts.setCron) {
216
+ const domain = opts.domain || config.getBaseUrl();
217
+ const suffix = domain && domain !== config.DEFAULT_BASE_URL ? ` --domain ${domain}` : '';
218
+ console.error(' Discovery completed successfully — only scheduled-run setup failed.');
219
+ console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <DISCOVERY_KEY>${suffix}`);
220
+ }
244
221
  process.exitCode = 1;
245
222
  }
246
223
  });
247
224
 
248
- // --- Schedule / Unschedule / Status (macOS only) ---
225
+ // --- Schedule / Unschedule / Status ---
249
226
 
250
227
  discover
251
- .command('schedule')
252
- .description('Set up a recurring 12-hour discovery scan via macOS LaunchAgent.')
228
+ .command('schedule', { hidden: true })
229
+ .description('Set up a daily scheduled discovery scan (cross-platform).')
253
230
  .option('--api-key <key>', 'Discovery API key (required)')
254
231
  .option('--domain <url>', 'Backend URL (defaults to configured backend)')
255
232
  .addHelpText('after', `
256
- Creates a macOS LaunchAgent that runs the discovery scan every 12 hours.
257
- Credentials are stored securely in the macOS Keychain (not in files).
233
+ Sets up a daily scheduled discovery scan using the OS-native scheduler:
234
+ - macOS: launchd LaunchAgent (~/Library/LaunchAgents)
235
+ - Linux: systemd --user timer (with Persistent=true), or crontab fallback
236
+ - Windows: Task Scheduler (with StartWhenAvailable for catch-up)
258
237
 
259
- The scan runs immediately on install, then every 12 hours.
260
- Logs are written to ~/Library/Logs/unbound/scan.log.
238
+ Credentials are stored in the OS-native secret store:
239
+ - macOS: Keychain
240
+ - Linux: ~/.unbound/scheduled-creds.json (mode 0600)
241
+ - Windows: Credential Manager
261
242
 
262
- Prerequisites:
263
- - macOS only (uses launchd)
264
- - Python 3 and curl must be installed
243
+ The scan runs immediately on install, then daily at 09:00 local time.
244
+ Alias of \`unbound discover --set-cron\`.
265
245
 
266
246
  Examples:
267
247
  $ unbound discover schedule --api-key KEY
@@ -269,19 +249,15 @@ Examples:
269
249
  `)
270
250
  .action(async (opts) => {
271
251
  try {
272
- if (process.platform !== 'darwin') {
273
- output.error('Scheduled scans are only supported on macOS.');
274
- process.exitCode = 1;
275
- return;
276
- }
277
-
278
252
  if (!opts.apiKey) {
279
253
  output.error('--api-key is required.');
280
254
  process.exitCode = 1;
281
255
  return;
282
256
  }
283
257
 
284
- await runDiscoverySchedule({ apiKey: opts.apiKey, domain: opts.domain });
258
+ const domain = opts.domain || config.getBaseUrl();
259
+ const { setupScheduledRun } = require('../scheduled');
260
+ await setupScheduledRun({ command: 'discover', apiKey: opts.apiKey, domain });
285
261
  } catch (err) {
286
262
  output.error(err.message);
287
263
  process.exitCode = 1;
@@ -290,23 +266,20 @@ Examples:
290
266
 
291
267
  discover
292
268
  .command('unschedule')
293
- .description('Remove the scheduled discovery scan and clean up (macOS only).')
269
+ .description('Remove the daily scheduled Unbound run and clean up stored credentials.')
294
270
  .addHelpText('after', `
295
- Removes the LaunchAgent, Keychain credentials, wrapper script,
296
- and install directory created by "unbound discover schedule".
271
+ Removes the scheduled job and credentials regardless of how the cron was set up —
272
+ "unbound discover --set-cron", "unbound discover schedule", or "unbound onboard --set-cron"
273
+ all register the same job.
297
274
 
298
275
  Examples:
299
276
  $ unbound discover unschedule
277
+ $ unbound onboard unschedule
300
278
  `)
301
279
  .action(async () => {
302
280
  try {
303
- if (process.platform !== 'darwin') {
304
- output.error('Scheduled scans are only supported on macOS.');
305
- process.exitCode = 1;
306
- return;
307
- }
308
-
309
- await runDiscoveryScript('setup-scheduled-scan.sh', '--uninstall');
281
+ const { uninstallScheduledRun } = require('../scheduled');
282
+ await uninstallScheduledRun();
310
283
  } catch (err) {
311
284
  output.error(err.message);
312
285
  process.exitCode = 1;
@@ -315,10 +288,14 @@ Examples:
315
288
 
316
289
  discover
317
290
  .command('status')
318
- .description('Show scheduled scan status and log paths (macOS only).')
291
+ .description('Show scheduled scan status and log paths (macOS only; use OS tools on Linux/Windows).')
319
292
  .action(() => {
320
293
  if (process.platform !== 'darwin') {
321
- output.error('Scheduled scans are only supported on macOS.');
294
+ output.error(
295
+ 'The status command only supports macOS (reads launchctl state).\n' +
296
+ ' Linux: systemctl --user status unbound-scheduled.timer\n' +
297
+ ' Windows: Get-ScheduledTask -TaskName "ai.getunbound.scheduled"'
298
+ );
322
299
  process.exitCode = 1;
323
300
  return;
324
301
  }
@@ -353,7 +330,7 @@ Examples:
353
330
  }
354
331
 
355
332
  output.keyValue([
356
- ['Scheduled', plistExists && isLoaded ? 'Yes (every 12 hours)' : 'No'],
333
+ ['Scheduled', plistExists && isLoaded ? 'Yes (daily at 09:00)' : 'No'],
357
334
  ['LaunchAgent', plistExists ? plistPath : 'Not installed'],
358
335
  ['Last scan', lastScan],
359
336
  ['Log file', logPath],
@@ -362,4 +339,4 @@ Examples:
362
339
  });
363
340
  }
364
341
 
365
- module.exports = { register, runDiscoveryScan, runDiscoverySchedule, classifyDiscoveryExit };
342
+ module.exports = { register, runDiscoveryScan, 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, runDiscoverySchedule } = require('./discover');
6
+ const { runDiscoveryScan } = require('./discover');
7
7
 
8
8
  /**
9
9
  * Builds the recovery-command suffix for partial-failure hints.
@@ -17,17 +17,17 @@ function domainHintSuffix(domain) {
17
17
  }
18
18
 
19
19
  function register(program) {
20
- program
20
+ const onboard = program
21
21
  .command('onboard')
22
22
  .description(
23
23
  'One-step user onboarding: install the default AI tools bundle and run device discovery. ' +
24
24
  'Runs `setup --all` followed by `discover` in a single command.'
25
25
  )
26
- .requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
27
- .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
26
+ .option('--api-key <key>', 'User API key (or set UNBOUND_API_KEY env var)')
27
+ .option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY env var)')
28
28
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
29
+ .option('--set-cron', 'Set up a daily background job to keep governance up to date')
29
30
  .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)')
31
31
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
32
32
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
33
33
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
@@ -35,8 +35,8 @@ function register(program) {
35
35
  Runs the full onboarding flow for an end user:
36
36
  1. Logs in with --api-key and stores credentials.
37
37
  2. Installs the default tool bundle: ${ALL_TOOLS.join(', ')}.
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
+ 3. Runs device discovery with --discovery-key. With --set-cron, sets up a
39
+ recurring daily scheduled scan (cross-platform) instead of a one-time scan.
40
40
 
41
41
  The user API key and discovery API key are separate keys obtained from
42
42
  different parts of the Unbound dashboard. Discovery uses its own key
@@ -50,11 +50,25 @@ For admin device enrollment via MDM, use \`unbound onboard-mdm\` instead.
50
50
  Examples:
51
51
  $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
52
52
  $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
53
- $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --cron
53
+ $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron
54
54
  $ sudo unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
55
55
  `)
56
56
  .action(async (opts) => {
57
+ const apiKeyOpt = opts.apiKey || process.env.UNBOUND_API_KEY;
58
+ const discoveryKeyOpt = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
59
+ if (!apiKeyOpt) {
60
+ output.error('--api-key is required (or set UNBOUND_API_KEY env var)');
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ if (!discoveryKeyOpt) {
65
+ output.error('--discovery-key is required (or set UNBOUND_DISCOVERY_KEY env var)');
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
57
70
  let setupSucceeded = false;
71
+ let discoverySucceeded = false;
58
72
  let discoveryDomain;
59
73
  try {
60
74
  // Persist URLs first, then login, then setup — order matters so the
@@ -76,7 +90,7 @@ Examples:
76
90
  discoveryDomain = opts.domain || backendUrl;
77
91
 
78
92
  await ensureLoggedIn({
79
- apiKey: opts.apiKey,
93
+ apiKey: apiKeyOpt,
80
94
  baseUrl: written.base_url,
81
95
  frontendUrl: written.frontend_url,
82
96
  });
@@ -93,32 +107,73 @@ Examples:
93
107
  console.log('');
94
108
  output.info('Step 2/2: Running device discovery');
95
109
  console.log('');
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 });
110
+ await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
111
+ discoverySucceeded = true;
112
+
113
+ if (opts.setCron) {
114
+ console.log('');
115
+ output.info('Setting up daily scheduled run');
116
+ const { setupScheduledRun } = require('../scheduled');
117
+ await setupScheduledRun({
118
+ command: 'onboard',
119
+ apiKey: apiKeyOpt,
120
+ discoveryKey: discoveryKeyOpt,
121
+ domain: discoveryDomain,
122
+ skipRunAtLoad: true,
123
+ });
102
124
  }
103
125
 
104
126
  console.log('');
105
127
  output.success('Onboarding complete');
106
128
  } catch (err) {
107
129
  if (!err.displayed) output.error(err.message);
108
- if (setupSucceeded) {
130
+ if (discoverySucceeded && opts.setCron) {
131
+ // Both setup and discovery completed; only the optional --set-cron
132
+ // step failed. Don't tell the user to re-run discovery — they already
133
+ // did. Tell them how to retry just the cron setup. discover schedule
134
+ // would install a discover-only cron, dropping the tool-bundle
135
+ // reinstall the user originally asked for, so the correct retry is
136
+ // re-running onboard with both keys.
137
+ const suffix = domainHintSuffix(discoveryDomain);
138
+ console.error(' Setup and discovery completed successfully — only scheduled-run setup failed.');
139
+ console.error(` Re-run cron setup with: unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
140
+ } else if (setupSucceeded && !discoverySucceeded) {
109
141
  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}`;
142
+ const retryCmd = `unbound discover --api-key <DISCOVERY_KEY>${suffix}`;
115
143
  console.error(' Tool setup completed successfully — only discovery failed.');
116
- console.error(` Re-run discovery only with: ${retryCmd}`);
144
+ if (opts.setCron) {
145
+ console.error(` Re-run with: unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
146
+ } else {
147
+ console.error(` Re-run discovery only with: ${retryCmd}`);
148
+ }
117
149
  }
118
150
  process.exitCode = 1;
119
151
  }
120
152
  });
121
153
 
154
+ // --- onboard unschedule ---
155
+
156
+ onboard
157
+ .command('unschedule')
158
+ .description('Remove the daily scheduled onboarding run and clean up stored credentials.')
159
+ .addHelpText('after', `
160
+ Removes the scheduled job and credentials created by "unbound onboard --set-cron"
161
+ (or "unbound discover --set-cron" / "unbound discover schedule" — all three
162
+ register the same job, so either unschedule command removes it).
163
+
164
+ Examples:
165
+ $ unbound onboard unschedule
166
+ `)
167
+ .action(async () => {
168
+ try {
169
+ const { uninstallScheduledRun } = require('../scheduled');
170
+ await uninstallScheduledRun();
171
+ } catch (err) {
172
+ output.error(err.message);
173
+ process.exitCode = 1;
174
+ }
175
+ });
176
+
122
177
  // --- MDM onboard (separate top-level command, mirrors `unbound onboard`) ---
123
178
 
124
179
  program
@@ -130,7 +185,7 @@ Examples:
130
185
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
131
186
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
132
187
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
133
- .option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
188
+ .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
134
189
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
135
190
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
136
191
  .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
  };
package/src/index.js CHANGED
@@ -91,7 +91,7 @@ MDM AI TOOLS DISCOVERY
91
91
  $ sudo unbound discover --api-key KEY Scan all users (requires root)
92
92
  $ unbound discover --api-key KEY Scan current user only
93
93
  $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
94
- $ unbound discover schedule --api-key KEY Set up 12h recurring scan (macOS)
94
+ $ unbound discover --set-cron --api-key KEY Set up daily scan at 09:00 (cross-platform)
95
95
  $ unbound discover unschedule Remove scheduled scan
96
96
  $ unbound discover status Show scan schedule and logs
97
97
 
@@ -0,0 +1,137 @@
1
+ const { spawn } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { shellEscape, downloadToFile, DISCOVER_BASE_URL } = require('./utils');
6
+
7
+ /**
8
+ * Cross-platform scheduled-run setup. Dispatches to the OS-appropriate
9
+ * script in coding-discovery-tool:
10
+ * - macOS/Linux: bash setup-scheduled-scan.sh
11
+ * - Windows: PowerShell setup-scheduled-scan.ps1
12
+ *
13
+ * The remote script handles launchd/crontab/Task Scheduler setup and
14
+ * credential storage (Keychain/file/Credential Manager). The CLI just
15
+ * passes the command + keys through.
16
+ *
17
+ * @param {object} opts
18
+ * @param {'onboard'|'discover'} opts.command - which subcommand the cron should run
19
+ * @param {string} opts.apiKey - api key (for onboard) or discovery key (for discover)
20
+ * @param {string} [opts.discoveryKey] - separate discovery key (onboard only)
21
+ * @param {string} [opts.domain] - backend domain to pass to the scheduled run
22
+ * @param {boolean} [opts.skipRunAtLoad] - macOS: set RunAtLoad=false (scan already ran)
23
+ */
24
+ async function setupScheduledRun(opts) {
25
+ const { command, apiKey, discoveryKey, domain, skipRunAtLoad } = opts;
26
+ if (!command || !apiKey) {
27
+ throw new Error('setupScheduledRun: command and apiKey are required');
28
+ }
29
+
30
+ if (process.platform === 'win32') {
31
+ return setupScheduledRunWindows(opts);
32
+ }
33
+
34
+ // macOS + Linux: same .sh script (the script branches on `uname -s` internally)
35
+ const args = [
36
+ '--command', shellEscape(command),
37
+ '--api-key', shellEscape(apiKey),
38
+ ];
39
+ if (discoveryKey) args.push('--discovery-key', shellEscape(discoveryKey));
40
+ if (domain) args.push('--domain', shellEscape(domain));
41
+ if (skipRunAtLoad) args.push('--no-run-at-load');
42
+
43
+ const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.sh`;
44
+ const cmd = `curl -fsSL "${url}" | bash -s -- ${args.join(' ')}`;
45
+
46
+ await new Promise((resolve, reject) => {
47
+ const child = spawn(cmd, { shell: true, stdio: 'inherit' });
48
+ child.on('close', (code) => {
49
+ if (code === 0) resolve();
50
+ else reject(new Error(`Scheduled-run setup failed with exit code ${code}`));
51
+ });
52
+ child.on('error', reject);
53
+ });
54
+ }
55
+
56
+ async function setupScheduledRunWindows(opts) {
57
+ const { command, apiKey, discoveryKey, domain } = opts;
58
+ const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.ps1`;
59
+ const tmp = path.join(os.tmpdir(), `unbound-scheduled-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
60
+ // Single try/finally covers both download and spawn so a mid-stream
61
+ // network drop still removes the partial temp file.
62
+ try {
63
+ try {
64
+ await downloadToFile(url, tmp);
65
+ } catch (err) {
66
+ throw new Error(
67
+ `${err.message}. Windows scheduled-run requires setup-scheduled-scan.ps1 in the coding-discovery-tool repo.`
68
+ );
69
+ }
70
+
71
+ const psArgs = [
72
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmp,
73
+ '-Command', command,
74
+ '-ApiKey', apiKey,
75
+ ];
76
+ if (discoveryKey) psArgs.push('-DiscoveryKey', discoveryKey);
77
+ if (domain) psArgs.push('-Domain', domain);
78
+
79
+ await new Promise((resolve, reject) => {
80
+ const child = spawn('powershell', psArgs, { stdio: 'inherit', shell: false, windowsHide: true });
81
+ child.on('close', (code) => {
82
+ if (code === 0) resolve();
83
+ else reject(new Error(`Scheduled-run setup failed with exit code ${code}`));
84
+ });
85
+ child.on('error', reject);
86
+ });
87
+ } finally {
88
+ try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Cross-platform uninstall: removes the scheduled task/launchd job/cron entry
94
+ * and stored credentials. Delegates to the same OS-specific setup-scheduled-scan
95
+ * script with --uninstall (or -Uninstall on Windows).
96
+ */
97
+ async function uninstallScheduledRun() {
98
+ if (process.platform === 'win32') {
99
+ const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.ps1`;
100
+ const tmp = path.join(os.tmpdir(), `unbound-scheduled-uninstall-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
101
+ try {
102
+ try {
103
+ await downloadToFile(url, tmp);
104
+ } catch (err) {
105
+ throw new Error(`${err.message}. Windows uninstall requires setup-scheduled-scan.ps1.`);
106
+ }
107
+ await new Promise((resolve, reject) => {
108
+ const child = spawn(
109
+ 'powershell',
110
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmp, '-Uninstall'],
111
+ { stdio: 'inherit', shell: false, windowsHide: true }
112
+ );
113
+ child.on('close', (code) => {
114
+ if (code === 0) resolve();
115
+ else reject(new Error(`Scheduled-run uninstall failed with exit code ${code}`));
116
+ });
117
+ child.on('error', reject);
118
+ });
119
+ } finally {
120
+ try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
121
+ }
122
+ return;
123
+ }
124
+
125
+ const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.sh`;
126
+ const cmd = `curl -fsSL "${url}" | bash -s -- --uninstall`;
127
+ await new Promise((resolve, reject) => {
128
+ const child = spawn(cmd, { shell: true, stdio: 'inherit' });
129
+ child.on('close', (code) => {
130
+ if (code === 0) resolve();
131
+ else reject(new Error(`Scheduled-run uninstall failed with exit code ${code}`));
132
+ });
133
+ child.on('error', reject);
134
+ });
135
+ }
136
+
137
+ module.exports = { setupScheduledRun, uninstallScheduledRun };
package/src/utils.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const readline = require('readline');
2
+ const fs = require('fs');
3
+ const https = require('https');
2
4
 
3
5
  function formatDate(dateStr) {
4
6
  if (!dateStr) return '-';
@@ -20,4 +22,42 @@ function parseCommaSeparated(value) {
20
22
  return value.split(',').map((s) => s.trim()).filter(Boolean);
21
23
  }
22
24
 
23
- module.exports = { formatDate, confirm, parseCommaSeparated };
25
+ /**
26
+ * Escapes a string for safe embedding in a shell command (single-quote wrap).
27
+ * Used wherever spawn(cmd, { shell: true }) is called with user-supplied values.
28
+ */
29
+ function shellEscape(str) {
30
+ return "'" + String(str).replace(/'/g, "'\\''") + "'";
31
+ }
32
+
33
+ /**
34
+ * Downloads a URL to a local file, following up to 3 redirects.
35
+ * Forwards response-stream errors to the Promise rejection so callers
36
+ * (and their finally blocks) can clean up partial downloads.
37
+ */
38
+ function downloadToFile(url, destPath) {
39
+ return new Promise((resolve, reject) => {
40
+ const request = (u, remaining) => {
41
+ https.get(u, (res) => {
42
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
43
+ res.resume();
44
+ return request(res.headers.location, remaining - 1);
45
+ }
46
+ if (res.statusCode !== 200) {
47
+ res.resume();
48
+ return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
49
+ }
50
+ const file = fs.createWriteStream(destPath);
51
+ res.pipe(file);
52
+ res.on('error', reject);
53
+ file.on('finish', () => file.close(resolve));
54
+ file.on('error', reject);
55
+ }).on('error', reject);
56
+ };
57
+ request(url, 3);
58
+ });
59
+ }
60
+
61
+ const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
62
+
63
+ module.exports = { formatDate, confirm, parseCommaSeparated, shellEscape, downloadToFile, DISCOVER_BASE_URL };
@@ -3,12 +3,12 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
- const discover = require('../src/commands/discover');
6
+ const scheduled = require('../src/scheduled');
7
7
 
8
- const DISCOVER_SRC_PATH = path.join(__dirname, '..', 'src', 'commands', 'discover.js');
8
+ const SCHEDULED_SRC_PATH = path.join(__dirname, '..', 'src', 'scheduled.js');
9
9
  const ONBOARD_SRC_PATH = path.join(__dirname, '..', 'src', 'commands', 'onboard.js');
10
10
 
11
- const discoverSrc = fs.readFileSync(DISCOVER_SRC_PATH, 'utf8');
11
+ const scheduledSrc = fs.readFileSync(SCHEDULED_SRC_PATH, 'utf8');
12
12
  const onboardSrc = fs.readFileSync(ONBOARD_SRC_PATH, 'utf8');
13
13
 
14
14
  // Extracts the textual body of a top-level `async function <name>(...) { ... }`
@@ -17,8 +17,6 @@ const onboardSrc = fs.readFileSync(ONBOARD_SRC_PATH, 'utf8');
17
17
  function extractFunctionBody(src, name) {
18
18
  const start = src.indexOf(`async function ${name}`);
19
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
20
  const paramOpen = src.indexOf('(', start);
23
21
  let pDepth = 0;
24
22
  let paramClose = -1;
@@ -43,80 +41,85 @@ function extractFunctionBody(src, name) {
43
41
  throw new Error(`unbalanced braces while extracting ${name}`);
44
42
  }
45
43
 
46
- const scheduleBody = extractFunctionBody(discoverSrc, 'runDiscoverySchedule');
44
+ const setupBody = extractFunctionBody(scheduledSrc, 'setupScheduledRun');
47
45
 
48
- test('runDiscoverySchedule is exported as a function', () => {
49
- assert.equal(typeof discover.runDiscoverySchedule, 'function');
46
+ test('setupScheduledRun is exported as a function', () => {
47
+ assert.equal(typeof scheduled.setupScheduledRun, 'function');
50
48
  });
51
49
 
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*\}\)/);
50
+ test('setupScheduledRun takes a single options object', () => {
51
+ assert.equal(scheduled.setupScheduledRun.length, 1);
57
52
  });
58
53
 
59
54
  test('the scheduled path runs setup-scheduled-scan.sh, never install.sh', () => {
60
55
  assert.ok(
61
- scheduleBody.includes("'setup-scheduled-scan.sh'"),
62
- 'runDiscoverySchedule must schedule setup-scheduled-scan.sh'
56
+ setupBody.includes('setup-scheduled-scan.sh'),
57
+ 'setupScheduledRun must use setup-scheduled-scan.sh'
63
58
  );
64
59
  assert.ok(
65
- !scheduleBody.includes('install.sh'),
66
- 'runDiscoverySchedule must not invoke install.sh'
60
+ !setupBody.includes('install.sh'),
61
+ 'setupScheduledRun must not invoke install.sh'
67
62
  );
68
63
  });
69
64
 
70
- // Core WEB-4499 regression guard: backfill is a one-time setup operation and
65
+ // Core regression guard: backfill is a one-time setup operation and
71
66
  // must be structurally impossible to reach the recurring scheduled scan.
72
- test('runDiscoverySchedule body contains no backfill reference', () => {
67
+ test('setupScheduledRun body contains no backfill reference', () => {
73
68
  assert.ok(
74
- !scheduleBody.toLowerCase().includes('backfill'),
69
+ !setupBody.toLowerCase().includes('backfill'),
75
70
  'backfill must never appear in the scheduled-scan code path'
76
71
  );
77
72
  });
78
73
 
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\)\}/);
74
+ test('setupScheduledRun shell-escapes command, apiKey, and domain', () => {
75
+ assert.match(setupBody, /shellEscape\(command\)/);
76
+ assert.match(setupBody, /shellEscape\(apiKey\)/);
77
+ assert.match(setupBody, /shellEscape\(domain\)/);
84
78
  });
85
79
 
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 }'
80
+ test('setupScheduledRun rejects when apiKey is missing', async () => {
81
+ await assert.rejects(
82
+ () => scheduled.setupScheduledRun({ command: 'discover' }),
83
+ /command and apiKey are required/
92
84
  );
93
85
  });
94
86
 
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);
87
+ test('setupScheduledRun rejects when command is missing', async () => {
88
+ await assert.rejects(
89
+ () => scheduled.setupScheduledRun({ apiKey: 'some-key' }),
90
+ /command and apiKey are required/
91
+ );
104
92
  });
105
93
 
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.
94
+ // onboard must call setupScheduledRun with command: 'onboard' and both keys
95
+ test('onboard schedules via setupScheduledRun with command onboard', () => {
109
96
  assert.match(
110
97
  onboardSrc,
111
- /const \{[^}]*runDiscoverySchedule[^}]*\} = require\('\.\/discover'\)/
98
+ /setupScheduledRun\(\{[^}]*command:\s*'onboard'[^}]*\}\)/s,
99
+ 'onboard must call setupScheduledRun with command: onboard'
112
100
  );
113
101
  });
114
102
 
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/
103
+ test('onboard passes both apiKey and discoveryKey to setupScheduledRun', () => {
104
+ const callIdx = onboardSrc.indexOf("command: 'onboard'");
105
+ assert.notEqual(callIdx, -1, 'expected command: onboard call site in onboard.js');
106
+ const open = onboardSrc.lastIndexOf('{', callIdx);
107
+ let depth = 0;
108
+ let close = -1;
109
+ for (let i = open; i < onboardSrc.length; i++) {
110
+ if (onboardSrc[i] === '{') depth++;
111
+ else if (onboardSrc[i] === '}') { depth--; if (depth === 0) { close = i; break; } }
112
+ }
113
+ const callArgs = onboardSrc.slice(open, close + 1);
114
+ assert.ok(callArgs.includes('apiKey'), 'setupScheduledRun call must include apiKey');
115
+ assert.ok(callArgs.includes('discoveryKey'), 'setupScheduledRun call must include discoveryKey');
116
+ assert.ok(!callArgs.toLowerCase().includes('backfill'), 'setupScheduledRun call must not include backfill');
117
+ });
118
+
119
+ test('onboard imports setupScheduledRun from ../scheduled', () => {
120
+ assert.match(
121
+ onboardSrc,
122
+ /require\(['"]\.\.\/scheduled['"]\)/,
123
+ 'onboard must require ../scheduled for setupScheduledRun'
121
124
  );
122
125
  });
@@ -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', () => {