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 +1 -1
- package/package.json +1 -1
- package/src/commands/discover.js +56 -79
- package/src/commands/onboard.js +78 -23
- package/src/commands/setup.js +15 -9
- package/src/index.js +1 -1
- package/src/scheduled.js +137 -0
- package/src/utils.js +41 -1
- package/test/onboard-cron.test.js +53 -50
- package/test/setup-args.test.js +20 -1
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
|
|
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
package/src/commands/discover.js
CHANGED
|
@@ -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
|
|
225
|
+
// --- Schedule / Unschedule / Status ---
|
|
249
226
|
|
|
250
227
|
discover
|
|
251
|
-
.command('schedule')
|
|
252
|
-
.description('Set up a
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
269
|
+
.description('Remove the daily scheduled Unbound run and clean up stored credentials.')
|
|
294
270
|
.addHelpText('after', `
|
|
295
|
-
Removes the
|
|
296
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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(
|
|
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 (
|
|
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,
|
|
342
|
+
module.exports = { register, runDiscoveryScan, classifyDiscoveryExit };
|
package/src/commands/onboard.js
CHANGED
|
@@ -3,7 +3,7 @@ const config = require('../config');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { ensureLoggedIn } = require('../auth');
|
|
5
5
|
const { runSetupAllBundle, runMdmSetupAllBundle, checkRoot, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
6
|
-
const { runDiscoveryScan
|
|
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
|
-
.
|
|
27
|
-
.
|
|
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
|
|
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:
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
package/src/commands/setup.js
CHANGED
|
@@ -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;
|
|
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/') ||
|
|
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
|
|
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
|
|
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
|
|
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
|
|
806
|
-
// print "not supported"; passing the flag to gateway-mode
|
|
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
|
|
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
|
|
package/src/scheduled.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
6
|
+
const scheduled = require('../src/scheduled');
|
|
7
7
|
|
|
8
|
-
const
|
|
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
|
|
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
|
|
44
|
+
const setupBody = extractFunctionBody(scheduledSrc, 'setupScheduledRun');
|
|
47
45
|
|
|
48
|
-
test('
|
|
49
|
-
assert.equal(typeof
|
|
46
|
+
test('setupScheduledRun is exported as a function', () => {
|
|
47
|
+
assert.equal(typeof scheduled.setupScheduledRun, 'function');
|
|
50
48
|
});
|
|
51
49
|
|
|
52
|
-
test('
|
|
53
|
-
|
|
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
|
-
|
|
62
|
-
'
|
|
56
|
+
setupBody.includes('setup-scheduled-scan.sh'),
|
|
57
|
+
'setupScheduledRun must use setup-scheduled-scan.sh'
|
|
63
58
|
);
|
|
64
59
|
assert.ok(
|
|
65
|
-
!
|
|
66
|
-
'
|
|
60
|
+
!setupBody.includes('install.sh'),
|
|
61
|
+
'setupScheduledRun must not invoke install.sh'
|
|
67
62
|
);
|
|
68
63
|
});
|
|
69
64
|
|
|
70
|
-
// Core
|
|
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('
|
|
67
|
+
test('setupScheduledRun body contains no backfill reference', () => {
|
|
73
68
|
assert.ok(
|
|
74
|
-
!
|
|
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('
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
assert.match(
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
/
|
|
98
|
+
/setupScheduledRun\(\{[^}]*command:\s*'onboard'[^}]*\}\)/s,
|
|
99
|
+
'onboard must call setupScheduledRun with command: onboard'
|
|
112
100
|
);
|
|
113
101
|
});
|
|
114
102
|
|
|
115
|
-
test('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
});
|
package/test/setup-args.test.js
CHANGED
|
@@ -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', () => {
|