unbound-cli 1.16.19 → 1.16.21

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,438 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { Command } = require('commander');
7
+ const childProcess = require('child_process');
8
+ const { detectInstalledTools, allUserHomes, accountHomes, TOOL_SIGNALS } = require('../src/toolDetect');
9
+
10
+ // Detection runs against a throwaway home so the result never depends on what
11
+ // the machine running the tests has installed.
12
+ // `links` maps a path to the path it points at, both relative to the fixture root.
13
+ function fixture(files = [], links = {}) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-detect-'));
15
+ const home = path.join(root, 'home');
16
+ fs.mkdirSync(home, { recursive: true });
17
+ for (const rel of files) {
18
+ const p = path.join(root, rel);
19
+ fs.mkdirSync(path.dirname(p), { recursive: true });
20
+ fs.writeFileSync(p, '');
21
+ }
22
+ for (const [rel, target] of Object.entries(links)) {
23
+ const p = path.join(root, rel);
24
+ fs.mkdirSync(path.dirname(p), { recursive: true });
25
+ fs.symlinkSync(path.join(root, target), p);
26
+ }
27
+ const opts = (extra = {}) => ({
28
+ platform: 'darwin',
29
+ homes: [home],
30
+ pathDirs: [path.join(root, 'path')],
31
+ machineBinDirs: [path.join(root, 'global-bin')],
32
+ osPackageBinDirs: [],
33
+ ...extra,
34
+ });
35
+ return { root, home, opts };
36
+ }
37
+
38
+ test('an empty device does not detect pi', () => {
39
+ const { opts } = fixture();
40
+ assert.deepEqual(detectInstalledTools(['pi'], opts()), { detected: [], missing: ['pi'] });
41
+ });
42
+
43
+ test('files pi writes for itself detect it', () => {
44
+ for (const marker of ['home/.pi/agent/auth.json', 'home/.pi/agent/sessions/x.jsonl']) {
45
+ const { opts } = fixture([marker]);
46
+ assert.deepEqual(detectInstalledTools(['pi'], opts()), { detected: ['pi'], missing: [] }, marker);
47
+ }
48
+ });
49
+
50
+ test('a pi binary detects it: per-user bin dir, nvm, PATH, machine-wide', () => {
51
+ for (const bin of [
52
+ 'home/.pi/agent/bin/pi', // where pi's own installer puts it
53
+ 'home/.local/bin/pi',
54
+ 'home/.nvm/versions/node/v22.1.0/bin/pi',
55
+ 'path/pi',
56
+ 'global-bin/pi',
57
+ ]) {
58
+ const { opts } = fixture([bin]);
59
+ assert.deepEqual(detectInstalledTools(['pi'], opts()).detected, ['pi'], bin);
60
+ }
61
+ });
62
+
63
+ // Debian and Ubuntu ship a digits-of-pi calculator at /usr/bin/pi. A regular
64
+ // file in a dir the OS package manager owns is the OS's own program.
65
+ test('a plain file named pi in an OS package dir is not the agent', () => {
66
+ const { root, opts } = fixture(['usr-bin/pi']);
67
+ const usrBin = path.join(root, 'usr-bin');
68
+ for (const where of ['machineBinDirs', 'pathDirs']) {
69
+ const result = detectInstalledTools(['pi'], opts({ [where]: [usrBin], osPackageBinDirs: [usrBin] }));
70
+ assert.deepEqual(result.detected, [], where);
71
+ }
72
+ });
73
+
74
+ test('pi linked into an OS package dir detects it', () => {
75
+ // npm with its prefix at /usr links the agent into /usr/bin.
76
+ const cli = 'usr-lib/node_modules/pi-coding-agent/dist/cli.js';
77
+ const { root, opts } = fixture([cli], { 'usr-bin/pi': cli });
78
+ const usrBin = path.join(root, 'usr-bin');
79
+ const result = detectInstalledTools(['pi'], opts({ machineBinDirs: [usrBin], osPackageBinDirs: [usrBin] }));
80
+ assert.deepEqual(result.detected, ['pi']);
81
+ });
82
+
83
+ test('an OS package dir reached through a link is still one', () => {
84
+ // /bin is a link to /usr/bin on a merged-usr system.
85
+ const { root, opts } = fixture(['usr-bin/pi'], { bin: 'usr-bin' });
86
+ const result = detectInstalledTools(['pi'], opts({
87
+ pathDirs: [path.join(root, 'bin')], osPackageBinDirs: [path.join(root, 'usr-bin')],
88
+ }));
89
+ assert.deepEqual(result.detected, []);
90
+ });
91
+
92
+ test('a link whose target is gone is not a signal', () => {
93
+ const { opts } = fixture([], { 'global-bin/pi': 'uninstalled/cli.js' });
94
+ assert.deepEqual(detectInstalledTools(['pi'], opts()).detected, []);
95
+ });
96
+
97
+ // The regression that matters: a device where `unbound setup pi` ran before has
98
+ // Unbound's own extension under pi's config dir. That must not count.
99
+ test('files written by Unbound\'s own setup are not install signals', () => {
100
+ const { opts } = fixture([
101
+ 'home/.pi/agent/extensions/unbound/index.js',
102
+ 'home/.pi/agent/extensions/unbound/index.ts',
103
+ 'home/.unbound/config.json',
104
+ ]);
105
+ assert.deepEqual(detectInstalledTools(['pi'], opts()), { detected: [], missing: ['pi'] });
106
+ });
107
+
108
+ test('windows binaries are matched by their .exe/.cmd/.ps1 shims', () => {
109
+ for (const shim of ['pi.cmd', 'pi.exe', 'pi.ps1']) {
110
+ const { opts } = fixture([`home/AppData/Roaming/npm/${shim}`]);
111
+ assert.deepEqual(detectInstalledTools(['pi'], opts()).detected, [], shim);
112
+ assert.deepEqual(detectInstalledTools(['pi'], opts({ platform: 'win32' })).detected, ['pi'], shim);
113
+ }
114
+ });
115
+
116
+ test('all-users scope finds pi when only another user has it', () => {
117
+ const { root, home, opts } = fixture(['other/.local/bin/pi']);
118
+ assert.deepEqual(detectInstalledTools(['pi'], opts()).detected, []);
119
+ assert.deepEqual(detectInstalledTools(['pi'], opts({ homes: [home, path.join(root, 'other')] })).detected, ['pi']);
120
+ });
121
+
122
+ // ---- a place that could not be inspected ----
123
+ // Absent and unreadable are different answers. Reporting the second as the
124
+ // first skipped setup on exactly the devices where nothing could be ruled out.
125
+ function withStatFailingUnder(blocked, code, run) {
126
+ const real = fs.statSync;
127
+ fs.statSync = (p, ...rest) => {
128
+ if (String(p).startsWith(blocked)) throw Object.assign(new Error(`${code}: ${p}`), { code });
129
+ return real(p, ...rest);
130
+ };
131
+ try {
132
+ return run();
133
+ } finally {
134
+ fs.statSync = real;
135
+ }
136
+ }
137
+
138
+ test('a home that cannot be inspected throws instead of reporting pi missing', () => {
139
+ for (const code of ['EACCES', 'EPERM', 'EIO']) {
140
+ const { root, home, opts } = fixture();
141
+ const locked = path.join(root, 'locked');
142
+ fs.mkdirSync(locked);
143
+ assert.throws(
144
+ () => withStatFailingUnder(locked, code, () => detectInstalledTools(['pi'], opts({ homes: [home, locked] }))),
145
+ { code },
146
+ );
147
+ }
148
+ });
149
+
150
+ test('a signal found elsewhere wins over a home that cannot be inspected', () => {
151
+ const { root, home, opts } = fixture(['home/.pi/agent/auth.json']);
152
+ const locked = path.join(root, 'locked');
153
+ fs.mkdirSync(locked);
154
+ const result = withStatFailingUnder(locked, 'EACCES', () => detectInstalledTools(['pi'], opts({ homes: [home, locked] })));
155
+ assert.deepEqual(result, { detected: ['pi'], missing: [] });
156
+ });
157
+
158
+ test('an nvm dir that cannot be listed throws instead of reporting pi missing', () => {
159
+ const { home, opts } = fixture();
160
+ const nvm = path.join(home, '.nvm', 'versions', 'node');
161
+ fs.mkdirSync(nvm, { recursive: true });
162
+ const real = fs.readdirSync;
163
+ fs.readdirSync = (p, ...rest) => {
164
+ if (String(p) === nvm) throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
165
+ return real(p, ...rest);
166
+ };
167
+ try {
168
+ assert.throws(() => detectInstalledTools(['pi'], opts()), { code: 'EACCES' });
169
+ } finally {
170
+ fs.readdirSync = real;
171
+ }
172
+ });
173
+
174
+ // ---- which homes the all-users scope looks in ----
175
+ test('a home that is not a direct child of the users root is found', () => {
176
+ // /home/DOMAIN/alice: the MDM setup script installs for that account, so
177
+ // detection has to look there too. A listing of /home sees only DOMAIN.
178
+ const { root, opts } = fixture(['users/DOMAIN/alice/.pi/agent/auth.json']);
179
+ const usersRoot = path.join(root, 'users');
180
+ const alice = path.join(usersRoot, 'DOMAIN', 'alice');
181
+
182
+ const listed = allUserHomes('linux', { root: usersRoot, accounts: [] });
183
+ assert.equal(listed.includes(alice), false);
184
+ assert.deepEqual(detectInstalledTools(['pi'], opts({ homes: listed.slice(1) })).detected, []);
185
+
186
+ const homes = allUserHomes('linux', { root: usersRoot, accounts: [alice] });
187
+ assert.equal(homes.includes(alice), true);
188
+ assert.deepEqual(detectInstalledTools(['pi'], opts({ homes: homes.slice(1) })).detected, ['pi']);
189
+ });
190
+
191
+ test('linux users named Shared or Guest are real accounts; on macOS they are not', () => {
192
+ const { root } = fixture(['users/Shared/x', 'users/Guest/x', 'users/alice/x', 'users/.hidden/x']);
193
+ const usersRoot = path.join(root, 'users');
194
+ const at = name => path.join(usersRoot, name);
195
+
196
+ const linux = allUserHomes('linux', { root: usersRoot, accounts: [] });
197
+ for (const name of ['Shared', 'Guest', 'alice']) assert.equal(linux.includes(at(name)), true, name);
198
+ assert.equal(linux.includes(at('.hidden')), false);
199
+
200
+ const mac = allUserHomes('darwin', { root: usersRoot, accounts: [at('Guest')] });
201
+ assert.equal(mac.includes(at('alice')), true);
202
+ for (const name of ['Shared', 'Guest']) assert.equal(mac.includes(at(name)), false, name);
203
+ });
204
+
205
+ test('an account home that does not exist is dropped', () => {
206
+ const { root } = fixture();
207
+ const homes = allUserHomes('linux', { root: path.join(root, 'users'), accounts: [path.join(root, 'users', 'ghost')] });
208
+ assert.deepEqual(homes, [os.homedir()]);
209
+ });
210
+
211
+ // ---- the account database ----
212
+ function withAccountLookup(fake, run) {
213
+ const real = childProcess.execFileSync;
214
+ childProcess.execFileSync = fake;
215
+ try {
216
+ return run();
217
+ } finally {
218
+ childProcess.execFileSync = real;
219
+ }
220
+ }
221
+
222
+ test('account homes are the database\'s homes under the platform prefix', () => {
223
+ const answer = JSON.stringify(['/home/alice', '/home/DOMAIN/bob', '/var/lib/postgres', '/root', '', null]);
224
+ const errors = [];
225
+ const homes = withAccountLookup(() => answer, () => accountHomes('linux', errors));
226
+ assert.deepEqual(homes, ['/home/alice', '/home/DOMAIN/bob']);
227
+ assert.deepEqual(errors, []);
228
+ assert.deepEqual(withAccountLookup(() => answer, () => accountHomes('win32', errors)), []);
229
+ });
230
+
231
+ // A setup script fails the same way, so it could not install into those homes
232
+ // either: forcing Pi here would only fail onboarding on a device without pi.
233
+ test('a missing python or a failing account database is not a detection error', () => {
234
+ for (const failure of [
235
+ Object.assign(new Error('spawn python3 ENOENT'), { code: 'ENOENT' }),
236
+ Object.assign(new Error('Command failed'), { status: 1 }),
237
+ ]) {
238
+ const errors = [];
239
+ const homes = withAccountLookup(() => { throw failure; }, () => accountHomes('linux', errors));
240
+ assert.deepEqual(homes, []);
241
+ assert.deepEqual(errors, [], failure.message);
242
+ }
243
+ });
244
+
245
+ // The database may be slow rather than down, and a setup script waits longer.
246
+ test('an account lookup that times out counts as a place that could not be inspected', () => {
247
+ const timeout = Object.assign(new Error('spawnSync python3 ETIMEDOUT'), { code: 'ETIMEDOUT' });
248
+ const errors = [];
249
+ assert.deepEqual(withAccountLookup(() => { throw timeout; }, () => accountHomes('linux', errors)), []);
250
+ assert.deepEqual(errors, [timeout]);
251
+
252
+ const { root } = fixture();
253
+ const seen = [];
254
+ withAccountLookup(() => { throw timeout; }, () => allUserHomes('linux', { errors: seen, root: path.join(root, 'users') }));
255
+ assert.deepEqual(seen, [timeout]);
256
+ });
257
+
258
+ test('an unreadable answer from the account lookup is kept as an error', () => {
259
+ const errors = [];
260
+ assert.deepEqual(withAccountLookup(() => 'Traceback (most recent call last)', () => accountHomes('linux', errors)), []);
261
+ assert.equal(errors.length, 1);
262
+ });
263
+
264
+ test('a tool with no signals is never ruled out', () => {
265
+ const { opts } = fixture();
266
+ assert.deepEqual(detectInstalledTools(['cursor', 'pi'], opts()), { detected: ['cursor'], missing: ['pi'] });
267
+ });
268
+
269
+ test('pi is the only tool with detection signals', () => {
270
+ assert.deepEqual(Object.keys(TOOL_SIGNALS), ['pi']);
271
+ });
272
+
273
+ // ---- onboard uses the detection result ----
274
+ const USER_BUNDLE = ['cursor', 'claude-code-subscription', 'codex-subscription', 'copilot', 'augment'];
275
+ const MDM_BUNDLE = ['cursor', 'claude-code-subscription', 'gemini-cli', 'codex-subscription', 'copilot', 'augment'];
276
+ const piDetected = (names) => ({ detected: names, missing: [] });
277
+ const piMissing = (names) => ({ detected: [], missing: names });
278
+
279
+ async function runOnboard({ isRoot, detect, fail, discoveryThrows }) {
280
+ for (const m of [
281
+ '../src/commands/onboard', '../src/commands/setup', '../src/commands/discover',
282
+ '../src/auth', '../src/config', '../src/output', '../src/toolDetect',
283
+ ]) {
284
+ delete require.cache[require.resolve(m)];
285
+ }
286
+ const calls = { bundle: [], candidates: null, allUsers: null, discovery: 0, lines: [], errors: [] };
287
+ const setup = require('../src/commands/setup');
288
+ const discover = require('../src/commands/discover');
289
+ const auth = require('../src/auth');
290
+ const config = require('../src/config');
291
+ const output = require('../src/output');
292
+ const toolDetect = require('../src/toolDetect');
293
+
294
+ setup.hasRootPrivileges = () => isRoot;
295
+ // The core bundle is called with no `tools`; Pi is its own call with ['pi'].
296
+ const bundle = async (_key, opts) => {
297
+ const step = opts.tools ? opts.tools.join(',') : 'core';
298
+ calls.bundle.push(step);
299
+ return { ok: step !== fail, skipped: [] };
300
+ };
301
+ setup.runSetupAllBundle = bundle;
302
+ setup.runMdmSetupAllBundle = bundle;
303
+ toolDetect.detectInstalledTools = (names, opts) => {
304
+ calls.candidates = names;
305
+ calls.allUsers = opts.allUsers;
306
+ return detect(names);
307
+ };
308
+ discover.runDiscoveryScan = async () => {
309
+ calls.discovery++;
310
+ if (discoveryThrows) throw new Error('scan failed');
311
+ };
312
+ auth.ensureLoggedIn = async () => {};
313
+ auth.fetchDeviceOwnerKey = async () => 'owner-key';
314
+ config.setUrls = () => ({});
315
+ config.getApiKey = () => 'resolved-key';
316
+ config.getBaseUrl = () => 'https://b.acme';
317
+ config.getFrontendUrl = () => 'https://f.acme';
318
+ config.getGatewayUrl = () => 'https://g.acme';
319
+ config.isLoggedIn = () => true;
320
+ output.success = (msg) => calls.lines.push(String(msg));
321
+ output.error = (msg) => calls.errors.push(String(msg));
322
+ for (const k of ['info', 'warn']) output[k] = (msg) => calls.lines.push(String(msg));
323
+
324
+ const { register } = require('../src/commands/onboard');
325
+ const program = new Command();
326
+ program.exitOverride();
327
+ register(program);
328
+ const savedLog = console.log;
329
+ const savedError = console.error;
330
+ const savedExit = process.exitCode;
331
+ console.log = (...args) => calls.lines.push(args.join(' '));
332
+ console.error = (...args) => calls.errors.push(args.join(' '));
333
+ process.exitCode = 0;
334
+ try {
335
+ await program.parseAsync(['node', 'unbound', 'onboard', '--api-key', 'k']);
336
+ calls.exitCode = process.exitCode;
337
+ } finally {
338
+ console.log = savedLog;
339
+ console.error = savedError;
340
+ process.exitCode = savedExit;
341
+ }
342
+ calls.piLines = calls.lines.filter(l => /\bPi\b/.test(l));
343
+ calls.setup = setup;
344
+ return calls;
345
+ }
346
+
347
+ test('onboard sets pi up after the user bundle when pi is detected', async () => {
348
+ const calls = await runOnboard({ isRoot: false, detect: piDetected });
349
+ assert.deepEqual(calls.candidates, ['pi']);
350
+ assert.equal(calls.allUsers, false);
351
+ assert.deepEqual(calls.bundle, ['core', 'pi']);
352
+ assert.equal(calls.discovery, 1);
353
+ assert.equal(calls.exitCode, 0);
354
+ assert.equal(calls.piLines.length, 1);
355
+ });
356
+
357
+ test('onboard installs exactly the user bundle when pi is not detected', async () => {
358
+ const calls = await runOnboard({ isRoot: false, detect: piMissing });
359
+ assert.deepEqual(calls.bundle, ['core']);
360
+ assert.equal(calls.discovery, 1);
361
+ assert.equal(calls.piLines.length, 1);
362
+ });
363
+
364
+ test('sudo onboard detects pi across all users and sets it up after the MDM bundle', async () => {
365
+ const calls = await runOnboard({ isRoot: true, detect: piDetected });
366
+ assert.deepEqual(calls.candidates, ['pi']);
367
+ assert.equal(calls.allUsers, true);
368
+ assert.deepEqual(calls.bundle, ['core', 'pi']);
369
+ assert.equal(calls.piLines.length, 1);
370
+ });
371
+
372
+ test('sudo onboard installs exactly the MDM bundle when pi is not detected', async () => {
373
+ const calls = await runOnboard({ isRoot: true, detect: piMissing });
374
+ assert.deepEqual(calls.bundle, ['core']);
375
+ assert.equal(calls.piLines.length, 1);
376
+ });
377
+
378
+ test('a detection crash still sets pi up, and onboarding exits 0', async () => {
379
+ for (const isRoot of [false, true]) {
380
+ const calls = await runOnboard({ isRoot, detect: () => { throw new Error('EPERM'); } });
381
+ assert.deepEqual(calls.bundle, ['core', 'pi']);
382
+ assert.equal(calls.discovery, 1);
383
+ assert.equal(calls.exitCode, 0);
384
+ assert.equal(calls.piLines.length, 1);
385
+ }
386
+ });
387
+
388
+ // Pi is opportunistic: before it joined onboarding, a device with pi still got
389
+ // its bundle and its discovery scan. A broken pi setup must not take those away.
390
+ test('a failed pi setup still runs discovery, and onboarding exits 1', async () => {
391
+ for (const isRoot of [false, true]) {
392
+ const calls = await runOnboard({ isRoot, detect: piDetected, fail: 'pi' });
393
+ assert.deepEqual(calls.bundle, ['core', 'pi']);
394
+ assert.equal(calls.discovery, 1);
395
+ assert.equal(calls.exitCode, 1);
396
+ assert.equal(calls.errors.length, 1);
397
+ assert.match(calls.errors[0], /Pi Coding Agent setup failed/);
398
+ assert.match(calls.errors[0], /unbound setup .*pi/);
399
+ assert.equal(calls.lines.some(l => /Onboarding complete/.test(l)), false);
400
+ }
401
+ });
402
+
403
+ // The hints printed for a failed scan retry the scan only. Without its own line
404
+ // the earlier Pi failure would be reported as "tool setup completed".
405
+ test('a failed pi setup is still reported when discovery fails after it', async () => {
406
+ for (const isRoot of [false, true]) {
407
+ const calls = await runOnboard({ isRoot, detect: piDetected, fail: 'pi', discoveryThrows: true });
408
+ assert.equal(calls.exitCode, 1);
409
+ const text = calls.errors.join('\n');
410
+ assert.match(text, /Pi Coding Agent setup also failed/);
411
+ assert.match(text, /unbound setup .*pi/);
412
+ assert.doesNotMatch(text, /Tool setup completed successfully/);
413
+ }
414
+ });
415
+
416
+ test('a failed scan after a clean pi setup keeps the usual message', async () => {
417
+ const calls = await runOnboard({ isRoot: false, detect: piDetected, discoveryThrows: true });
418
+ const text = calls.errors.join('\n');
419
+ assert.match(text, /Tool setup completed successfully — only discovery failed/);
420
+ assert.doesNotMatch(text, /Pi Coding Agent/);
421
+ });
422
+
423
+ test('a failed core bundle stops before pi and before discovery', async () => {
424
+ for (const isRoot of [false, true]) {
425
+ const calls = await runOnboard({ isRoot, detect: piDetected, fail: 'core' });
426
+ assert.deepEqual(calls.bundle, ['core']);
427
+ assert.equal(calls.discovery, 0);
428
+ assert.equal(calls.exitCode, 1);
429
+ }
430
+ });
431
+
432
+ test('the default bundles themselves never gain pi', async () => {
433
+ for (const isRoot of [false, true]) {
434
+ const { setup } = await runOnboard({ isRoot, detect: piDetected });
435
+ assert.deepEqual(setup.ALL_TOOLS, USER_BUNDLE);
436
+ assert.deepEqual(setup.MDM_ALL_TOOLS, MDM_BUNDLE);
437
+ }
438
+ });