unbound-cli 1.1.8 → 1.3.0

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.
@@ -0,0 +1,288 @@
1
+ // Per-tool local health detection shared by `unbound doctor` and `unbound status`.
2
+ //
3
+ // Source of truth for what each tool installs is the setup repo's per-tool
4
+ // setup.py (mirrored here). Each tool has STRUCTURAL artifacts (a config file
5
+ // with an Unbound marker + an optional hook/script file) that decide whether it
6
+ // is installed, plus AUXILIARY wiring (an env var holding the api key, or the
7
+ // gateway URL). Env vars are persisted to a shell rc file (Unix) or the user
8
+ // registry (Windows), NOT reliably to the current process env, so we read those.
9
+ //
10
+ // Install state (matches the doctor spec):
11
+ // none of the structural artifacts present -> not installed
12
+ // all structural present AND all wiring ok -> healthy
13
+ // some present (or wiring broken) -> tampered
14
+ // nothing local but an org MDM install found -> managed by MDM
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const HOME = os.homedir();
22
+ const GATEWAY_DEFAULT = 'https://api.getunbound.ai';
23
+
24
+ function expand(p) {
25
+ return p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
26
+ }
27
+
28
+ function fileExists(p) {
29
+ try {
30
+ return fs.statSync(expand(p)).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function readText(p) {
37
+ try {
38
+ return fs.readFileSync(expand(p), 'utf8');
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function readJson(p) {
45
+ const text = readText(p);
46
+ if (text == null) return null;
47
+ try {
48
+ return JSON.parse(text.replace(/^\uFEFF/, '')); // tolerate a UTF-8 BOM
49
+ } catch {
50
+ return undefined; // file exists but is corrupt
51
+ }
52
+ }
53
+
54
+ // Candidate shell rc files where the setup scripts append `export NAME=...`.
55
+ function rcFiles() {
56
+ if (process.platform === 'win32') return [];
57
+ if (process.platform === 'darwin') return ['~/.zprofile', '~/.bash_profile', '~/.zshrc', '~/.bashrc'];
58
+ return ['~/.zshrc', '~/.bashrc', '~/.profile'];
59
+ }
60
+
61
+ // Read a persisted env var: live process env first, then the shell rc (Unix) or
62
+ // the user registry (Windows). Returns the value so callers can flag mismatch.
63
+ function readEnvVar(name) {
64
+ // `in`, not truthiness: an env var explicitly set to "" is "set but empty", not
65
+ // absent — without this we'd fall through and return a stale rc-file value,
66
+ // masking the fact that the live env blanked the key.
67
+ if (name in process.env) return { found: true, value: process.env[name], source: 'process env' };
68
+
69
+ if (process.platform === 'win32') {
70
+ try {
71
+ const r = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8' });
72
+ if (r.status === 0 && r.stdout) {
73
+ const m = r.stdout.match(new RegExp(`${name}\\s+REG_[A-Z_]+\\s+(.*)`));
74
+ if (m) return { found: true, value: m[1].trim(), source: 'user registry' };
75
+ }
76
+ } catch {
77
+ /* fall through */
78
+ }
79
+ return { found: false };
80
+ }
81
+
82
+ const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
83
+ for (const rc of rcFiles()) {
84
+ const text = readText(rc);
85
+ if (!text) continue;
86
+ const m = text.match(re);
87
+ if (m) {
88
+ const val = m[1].trim().replace(/^["']|["']$/g, '');
89
+ return { found: true, value: val, source: path.basename(expand(rc)) };
90
+ }
91
+ }
92
+ return { found: false };
93
+ }
94
+
95
+ // --- check builders. Each returns { name, ok, kind, detail, summary, warn? }. ---
96
+ // kind: 'structural' (decides installed) | 'aux' (wiring health only).
97
+ // summary: a short reason shown inline on the tampered line.
98
+
99
+ function configCheck(label, file, marker, opts = {}) {
100
+ const kind = opts.kind || 'structural';
101
+ const data = marker.json ? readJson(file) : readText(file);
102
+ let ok = false;
103
+ let detail;
104
+ let summary;
105
+ if (data == null) {
106
+ detail = `not found (${file})`;
107
+ summary = opts.short || `${label.toLowerCase()} not found`;
108
+ } else if (data === undefined) {
109
+ detail = `unreadable / corrupt (${file})`;
110
+ summary = `${label.toLowerCase()} unreadable`;
111
+ } else {
112
+ ok = marker.test(data);
113
+ detail = ok ? file : `present but missing the Unbound integration (${file})`;
114
+ summary = opts.short || `${label.toLowerCase()} not integrated`;
115
+ }
116
+ return { name: label, ok, kind, detail, summary };
117
+ }
118
+
119
+ function scriptCheck(label, file, kind = 'structural') {
120
+ const ok = fileExists(file);
121
+ return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
122
+ }
123
+
124
+ function envCheck(label, name, expected, kind = 'aux') {
125
+ const base = label.replace(/ env$/, '');
126
+ const r = readEnvVar(name);
127
+ // An explicitly-empty value is broken wiring, same as absent.
128
+ if (!r.found || !r.value) return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
129
+ // A value that doesn't match what setup wrote (stale key, wrong gateway URL) is a
130
+ // real misconfiguration: mark it not-ok so the tool reports tampered, not healthy.
131
+ if (expected && r.value !== expected) {
132
+ return { name: label, ok: false, kind, warn: true, summary: `${base} differs from setup`, detail: `${name} set (${r.source}) but differs from what setup configured` };
133
+ }
134
+ return { name: label, ok: true, kind, detail: `${name} set (${r.source})`, summary: `${base} set` };
135
+ }
136
+
137
+ // MDM (org-wide) install detection. An Unbound MDM install is only real when the
138
+ // system managed config REFERENCES the Unbound hook AND the hook script exists.
139
+ // A plain managed-settings.json (Claude Enterprise / generic MDM) does NOT count,
140
+ // and a managed config that points at a missing hook script is a broken (tampered)
141
+ // MDM install, not a healthy one. Returns { status: 'healthy'|'tampered'|null, checks }.
142
+ function mdmDetect(family, dirOverride) {
143
+ // Only Cursor / Claude Code / Codex have a managed (MDM) directory. Copilot and
144
+ // Gemini have none — their org install writes the same per-user config into
145
+ // every profile, so they're checked exactly like a user-level tool.
146
+ const configName = { cursor: 'hooks.json', 'claude-code': 'managed-settings.json', codex: 'managed-settings.json' };
147
+ if (!configName[family]) return { status: null, checks: [] };
148
+
149
+ const mac = '/Library/Application Support';
150
+ const win = process.env.ProgramData || 'C:\\ProgramData';
151
+ const dirs = {
152
+ cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(win, 'Cursor') },
153
+ 'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(win, 'ClaudeCode') },
154
+ codex: { darwin: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(win, 'Codex') },
155
+ };
156
+ const dir = dirOverride || dirs[family][process.platform] || dirs[family].linux;
157
+ const configPath = path.join(dir, configName[family]);
158
+ const scriptPath = path.join(dir, 'hooks', 'unbound.py');
159
+
160
+ const cfgText = readText(configPath);
161
+ if (cfgText == null || !cfgText.includes('unbound.py')) return { status: null, checks: [] };
162
+
163
+ const scriptOk = fileExists(scriptPath);
164
+ const checks = [
165
+ { name: 'MDM config', ok: true, kind: 'structural', detail: configPath, summary: 'managed config' },
166
+ { name: 'MDM hook script', ok: scriptOk, kind: 'structural', summary: 'managed hook not installed', detail: scriptOk ? scriptPath : `managed config references a hook that isn't installed (${scriptPath})` },
167
+ ];
168
+ return { status: scriptOk ? 'healthy' : 'tampered', checks };
169
+ }
170
+
171
+ // Marker that a claude/codex/cursor hooks block references unbound.py.
172
+ function refsUnbound(obj) {
173
+ return JSON.stringify(obj || {}).includes('unbound.py');
174
+ }
175
+
176
+ // One descriptor per (tool, mode). `family` groups the two-mode tools so the
177
+ // collapsed view shows a single line per product.
178
+ function buildVariants(gatewayUrl, apiKey) {
179
+ const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
180
+ return [
181
+ {
182
+ key: 'cursor', label: 'Cursor', family: 'cursor', mode: null,
183
+ checks: () => [
184
+ configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
185
+ scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
186
+ envCheck('API key env', 'UNBOUND_CURSOR_API_KEY', apiKey),
187
+ ],
188
+ },
189
+ {
190
+ key: 'claude-code-subscription', label: 'Claude Code (subscription)', family: 'claude-code', mode: 'subscription',
191
+ checks: () => [
192
+ configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
193
+ scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
194
+ envCheck('API key env', 'UNBOUND_CLAUDE_API_KEY', apiKey),
195
+ ],
196
+ },
197
+ {
198
+ key: 'claude-code-gateway', label: 'Claude Code (gateway)', family: 'claude-code', mode: 'gateway',
199
+ checks: () => [
200
+ configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
201
+ scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
202
+ envCheck('API key env', 'UNBOUND_API_KEY', apiKey),
203
+ envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
204
+ ],
205
+ },
206
+ {
207
+ key: 'codex-subscription', label: 'Codex (subscription)', family: 'codex', mode: 'subscription',
208
+ checks: () => [
209
+ configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
210
+ configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
211
+ scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
212
+ envCheck('API key env', 'UNBOUND_CODEX_API_KEY', apiKey),
213
+ ],
214
+ },
215
+ {
216
+ key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
217
+ checks: () => [
218
+ configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
219
+ envCheck('API key env', 'OPENAI_API_KEY', apiKey),
220
+ ],
221
+ },
222
+ {
223
+ key: 'copilot', label: 'GitHub Copilot', family: 'copilot', mode: null,
224
+ checks: () => [
225
+ // Copilot has no managed (MDM) directory: the org install writes the same
226
+ // ~/.copilot config into every user profile, so it's checked like a
227
+ // user-level tool and never reports "managed by MDM".
228
+ configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound }),
229
+ scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'),
230
+ envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey),
231
+ ],
232
+ },
233
+ // Gemini CLI is intentionally omitted here — it isn't part of `setup --all`
234
+ // and has no managed directory. Add it back when its scope is settled.
235
+ ];
236
+ }
237
+
238
+ function statusOf(checks) {
239
+ const structural = checks.filter((c) => c.kind === 'structural');
240
+ const present = structural.filter((c) => c.ok);
241
+ if (present.length === 0) return 'not-installed';
242
+ if (present.length === structural.length && checks.every((c) => c.ok)) return 'healthy';
243
+ return 'tampered';
244
+ }
245
+
246
+ function detectVariant(variant) {
247
+ const checks = variant.checks();
248
+ return { key: variant.key, label: variant.label, family: variant.family, mode: variant.mode, status: statusOf(checks), checks };
249
+ }
250
+
251
+ // Collapse the per-(tool,mode) variants to one entry per product family. Picks
252
+ // the installed/tampered mode if any; otherwise reports managed-by-mdm or
253
+ // not-installed. This is what both `doctor` and `status` render.
254
+ // `_mdmDirs` (test-only) overrides the system MDM directories per family so the
255
+ // org-managed scenarios can be exercised without writing under /Library or /etc.
256
+ function detectTools({ gatewayUrl, apiKey, _mdmDirs } = {}) {
257
+ const variants = buildVariants(gatewayUrl, apiKey).map(detectVariant);
258
+ const families = [];
259
+ const seen = new Set();
260
+ for (const v of variants) {
261
+ if (seen.has(v.family)) continue;
262
+ seen.add(v.family);
263
+ const sameFamily = variants.filter((x) => x.family === v.family);
264
+ const present = sameFamily.filter((x) => x.status !== 'not-installed');
265
+ if (present.length) {
266
+ // Prefer a tampered variant so a conflicting/partial install surfaces (and
267
+ // `--fix` reinstalls it). Flag when more than one mode is present at once.
268
+ const active = present.find((x) => x.status === 'tampered') || present[0];
269
+ if (present.length > 1) active.conflict = true;
270
+ active.label = active.label.replace(/ \(.*\)$/, ''); // mode is shown via .mode
271
+ families.push(active);
272
+ } else {
273
+ const family = v.family;
274
+ const label = v.label.replace(/ \(.*\)$/, '');
275
+ const mdm = mdmDetect(family, _mdmDirs && _mdmDirs[family]);
276
+ if (mdm.status === 'healthy') {
277
+ families.push({ key: family, label, family, mode: null, status: 'managed-by-mdm', checks: mdm.checks, scope: 'mdm' });
278
+ } else if (mdm.status === 'tampered') {
279
+ families.push({ key: family, label, family, mode: null, status: 'tampered', checks: mdm.checks, scope: 'mdm' });
280
+ } else {
281
+ families.push({ key: family, label, family, mode: null, status: 'not-installed', checks: [] });
282
+ }
283
+ }
284
+ }
285
+ return families;
286
+ }
287
+
288
+ module.exports = { detectTools, statusOf, readEnvVar, GATEWAY_DEFAULT };
@@ -0,0 +1,44 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+ const setup = require('../src/commands/setup');
5
+
6
+ // WEB-4757: `onboard`/`setup` are single commands; MDM vs user scope is
7
+ // auto-detected from sudo/root, so the separate `onboard-mdm` and `setup mdm`
8
+ // commands no longer exist. These guard against a regression that re-splits or
9
+ // drops them.
10
+
11
+ function buildProgram() {
12
+ const program = new Command();
13
+ require('../src/commands/onboard').register(program);
14
+ setup.register(program);
15
+ return program;
16
+ }
17
+
18
+ test('WEB-4757: onboard/setup are single top-level commands, no -mdm variants', () => {
19
+ const top = buildProgram().commands.map((c) => c.name());
20
+ assert.ok(top.includes('onboard'), 'onboard is registered');
21
+ assert.ok(top.includes('setup'), 'setup is registered');
22
+ assert.ok(!top.includes('onboard-mdm'), 'onboard-mdm is gone');
23
+ });
24
+
25
+ test('WEB-4757: setup has no `mdm` subcommand; onboard keeps `unschedule`', () => {
26
+ const program = buildProgram();
27
+ const setupCmd = program.commands.find((c) => c.name() === 'setup');
28
+ const onboardCmd = program.commands.find((c) => c.name() === 'onboard');
29
+ assert.ok(!setupCmd.commands.some((c) => c.name() === 'mdm'), 'setup mdm subcommand is gone');
30
+ assert.ok(onboardCmd.commands.some((c) => c.name() === 'unschedule'), 'onboard unschedule is kept');
31
+ });
32
+
33
+ test('WEB-4757: onboard/setup accept --admin-api-key as a back-compat alias', () => {
34
+ const program = buildProgram();
35
+ const onboardCmd = program.commands.find((c) => c.name() === 'onboard');
36
+ const setupCmd = program.commands.find((c) => c.name() === 'setup');
37
+ assert.ok(onboardCmd.options.some((o) => o.long === '--admin-api-key'), 'onboard --admin-api-key');
38
+ assert.ok(setupCmd.options.some((o) => o.long === '--admin-api-key'), 'setup --admin-api-key');
39
+ });
40
+
41
+ test('WEB-4757: scope helper is exported; the throwing checkRoot is gone', () => {
42
+ assert.equal(typeof setup.hasRootPrivileges, 'function');
43
+ assert.equal(setup.checkRoot, undefined);
44
+ });
@@ -0,0 +1,31 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { fixExitCode } = require('../src/commands/doctor');
5
+
6
+ // `--fix` exit code: a CI gate runs `unbound doctor --fix` and reads the exit code.
7
+ // It must be 0 only when the machine is actually clean after the repair.
8
+
9
+ test('fixExitCode: spawn/signal error (null status) → 1', () => {
10
+ assert.equal(fixExitCode(null, false, false), 1);
11
+ assert.equal(fixExitCode(null, true, true), 1);
12
+ });
13
+
14
+ test('fixExitCode: setup failure propagates its status', () => {
15
+ assert.equal(fixExitCode(2, false, false), 2);
16
+ assert.equal(fixExitCode(1, true, true), 1);
17
+ });
18
+
19
+ test('fixExitCode: non-root, user tools fixed but MDM still tampered → 1 (not clean)', () => {
20
+ assert.equal(fixExitCode(0, false, true), 1);
21
+ });
22
+
23
+ test('fixExitCode: non-root, nothing org-managed left → 0 (clean)', () => {
24
+ assert.equal(fixExitCode(0, false, false), 0);
25
+ });
26
+
27
+ test('fixExitCode: root run repaired the MDM tools too → 0 (clean, no false failure)', () => {
28
+ // Regression guard: sudo `--fix` reinstalls MDM tools, so a status-0 run is clean
29
+ // even though mdmRemaining was true going in.
30
+ assert.equal(fixExitCode(0, true, true), 0);
31
+ });
@@ -0,0 +1,114 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // WEB-4757: scope is auto-detected from sudo/root. These tests verify the
6
+ // permission primitive (hasRootPrivileges) and that `onboard` actually routes
7
+ // to the MDM bundle when root and the user bundle when not — without touching
8
+ // the network or running any setup script (all downstream deps are stubbed).
9
+
10
+ // ---- 1. The sudo permission check itself ----
11
+ test('hasRootPrivileges: true when effective uid is 0 (sudo/root)', () => {
12
+ delete require.cache[require.resolve('../src/commands/setup')];
13
+ const setup = require('../src/commands/setup');
14
+ const orig = process.getuid;
15
+ try {
16
+ process.getuid = () => 0;
17
+ assert.equal(setup.hasRootPrivileges(), true);
18
+ } finally {
19
+ process.getuid = orig;
20
+ }
21
+ });
22
+
23
+ test('hasRootPrivileges: false when effective uid is not 0 (no sudo)', () => {
24
+ delete require.cache[require.resolve('../src/commands/setup')];
25
+ const setup = require('../src/commands/setup');
26
+ const orig = process.getuid;
27
+ try {
28
+ process.getuid = () => 1000;
29
+ assert.equal(setup.hasRootPrivileges(), false);
30
+ } finally {
31
+ process.getuid = orig;
32
+ }
33
+ });
34
+
35
+ // ---- 2. onboard routes by detected scope ----
36
+ // Reloads the module graph with stubbed deps so we can observe which bundle
37
+ // helper the single `onboard` command invokes.
38
+ async function runOnboard(isRoot, { setCron = false } = {}) {
39
+ for (const m of [
40
+ '../src/commands/onboard',
41
+ '../src/commands/setup',
42
+ '../src/commands/discover',
43
+ '../src/auth',
44
+ '../src/config',
45
+ '../src/output',
46
+ '../src/scheduled',
47
+ ]) {
48
+ delete require.cache[require.resolve(m)];
49
+ }
50
+
51
+ const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null };
52
+ const setup = require('../src/commands/setup');
53
+ const discover = require('../src/commands/discover');
54
+ const auth = require('../src/auth');
55
+ const config = require('../src/config');
56
+ const output = require('../src/output');
57
+ const scheduled = require('../src/scheduled');
58
+
59
+ setup.hasRootPrivileges = () => isRoot;
60
+ setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
61
+ setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
62
+ discover.runDiscoveryScan = async () => { calls.discovery++; };
63
+ auth.ensureLoggedIn = async () => { calls.loggedIn++; };
64
+ scheduled.setupScheduledRun = async (o) => { calls.cron = o; };
65
+ config.setUrls = () => ({});
66
+ config.getApiKey = () => 'resolved-key';
67
+ config.getBaseUrl = () => 'https://b.acme';
68
+ config.getFrontendUrl = () => 'https://f.acme';
69
+ config.getGatewayUrl = () => 'https://g.acme';
70
+ config.isLoggedIn = () => true;
71
+ for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
72
+
73
+ const { register } = require('../src/commands/onboard');
74
+ const program = new Command();
75
+ program.exitOverride();
76
+ register(program);
77
+ const argv = ['node', 'unbound', 'onboard', '--api-key', 'k', '--discovery-key', 'd'];
78
+ if (setCron) argv.push('--set-cron');
79
+ await program.parseAsync(argv);
80
+ return calls;
81
+ }
82
+
83
+ test('onboard with sudo/root → installs the MDM bundle (all users), no login', async () => {
84
+ const calls = await runOnboard(true);
85
+ assert.equal(calls.mdm, 1, 'MDM bundle installed');
86
+ assert.equal(calls.user, 0, 'user bundle NOT installed');
87
+ assert.equal(calls.loggedIn, 0, 'MDM scope does not run ensureLoggedIn');
88
+ assert.equal(calls.discovery, 1, 'discovery still runs');
89
+ });
90
+
91
+ test('onboard without sudo → logs in and installs the user bundle', async () => {
92
+ const calls = await runOnboard(false);
93
+ assert.equal(calls.user, 1, 'user bundle installed');
94
+ assert.equal(calls.mdm, 0, 'MDM bundle NOT installed');
95
+ assert.equal(calls.loggedIn, 1, 'user scope runs ensureLoggedIn');
96
+ assert.equal(calls.discovery, 1, 'discovery still runs');
97
+ });
98
+
99
+ // --set-cron under sudo schedules discovery only (not a daily full MDM
100
+ // re-install) and stores the discovery key, never the admin key.
101
+ test('onboard --set-cron with sudo → schedules discovery-only with the discovery key', async () => {
102
+ const calls = await runOnboard(true, { setCron: true });
103
+ assert.ok(calls.cron, 'a scheduled run was set up');
104
+ assert.equal(calls.cron.command, 'discover', 'schedules discover, not full onboard');
105
+ assert.equal(calls.cron.apiKey, 'd', 'uses the discovery key, not the admin key');
106
+ assert.equal(calls.cron.discoveryKey, undefined, 'no separate admin/discovery key passed');
107
+ });
108
+
109
+ test('onboard --set-cron without sudo → schedules the full onboard run', async () => {
110
+ const calls = await runOnboard(false, { setCron: true });
111
+ assert.ok(calls.cron, 'a scheduled run was set up');
112
+ assert.equal(calls.cron.command, 'onboard', 'user scope keeps the full onboard cron');
113
+ assert.equal(calls.cron.discoveryKey, 'd', 'discovery key forwarded for the onboard cron');
114
+ });