unbound-cli 1.16.19 → 1.16.20
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/onboard.js +68 -3
- package/src/commands/setup.js +8 -6
- package/src/toolDetect.js +239 -0
- package/test/onboard-failure-exit.test.js +3 -0
- package/test/onboard-scope.test.js +3 -0
- package/test/tool-detect.test.js +438 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ unbound setup cursor
|
|
|
32
32
|
|
|
33
33
|
## Onboarding (one command)
|
|
34
34
|
|
|
35
|
-
For new users, the fastest path from install to a fully-configured device is the `onboard` command. It logs you in, installs the default tool bundle (Cursor, Claude Code hooks, Codex hooks), and runs device discovery — all in one step.
|
|
35
|
+
For new users, the fastest path from install to a fully-configured device is the `onboard` command. It logs you in, installs the default tool bundle (Cursor, Claude Code hooks, Codex hooks), and runs device discovery — all in one step. Pi Coding Agent is also set up, after the bundle, when pi is already installed on the device, and is skipped otherwise (`unbound setup pi` sets it up regardless). A failed Pi setup does not stop discovery.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# End user
|
package/package.json
CHANGED
package/src/commands/onboard.js
CHANGED
|
@@ -3,8 +3,9 @@ const config = require('../config');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const telemetry = require('../telemetry');
|
|
5
5
|
const { ensureLoggedIn, fetchDeviceOwnerKey } = require('../auth');
|
|
6
|
-
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
6
|
+
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS, SETUP_TOOL_MAP, MDM_TOOLS } = require('./setup');
|
|
7
7
|
const { runDiscoveryScan } = require('./discover');
|
|
8
|
+
const toolDetect = require('../toolDetect');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Builds the recovery-command suffix for partial-failure hints.
|
|
@@ -17,6 +18,34 @@ function domainHintSuffix(domain) {
|
|
|
17
18
|
return ` --domain ${domain}`;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Whether onboarding should also set up Pi: only when pi is present on the
|
|
23
|
+
* device. Pi is the one tool onboarding installs only where it is already in
|
|
24
|
+
* use. A check that could not be completed counts as detected, so a device is
|
|
25
|
+
* never left ungoverned because the check itself broke.
|
|
26
|
+
*/
|
|
27
|
+
function shouldSetUpPi(label, { allUsers }) {
|
|
28
|
+
let detected;
|
|
29
|
+
try {
|
|
30
|
+
detected = toolDetect.detectInstalledTools(['pi'], { allUsers }).detected.includes('pi');
|
|
31
|
+
} catch (err) {
|
|
32
|
+
telemetry.captureError(err, { tags: { flow: 'onboard-detect' } });
|
|
33
|
+
output.warn(`Could not check whether ${label} is installed — setting it up anyway.`);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (!detected) {
|
|
37
|
+
console.log(output.colors.dim(` ${label} not detected — skipped (run \`unbound setup pi\` to set it up regardless)`));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
output.info(`${label} detected — setting it up after the bundle`);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The command that retries a failed Pi setup in this scope. */
|
|
45
|
+
function piRetryCommand(isMdm) {
|
|
46
|
+
return isMdm ? elevated('unbound setup --api-key <ADMIN_KEY> pi') : 'unbound setup pi';
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
function register(program) {
|
|
21
50
|
const onboard = program
|
|
22
51
|
.command('onboard')
|
|
@@ -39,6 +68,8 @@ Runs the full onboarding flow, auto-detecting scope from your privileges:
|
|
|
39
68
|
With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and scans
|
|
40
69
|
every user on the device. Without sudo, installs the user bundle
|
|
41
70
|
(${ALL_TOOLS.join(', ')}) and scans just the current user.
|
|
71
|
+
Pi Coding Agent (pi) is also set up, after the bundle, only when pi is detected
|
|
72
|
+
on the device; use \`unbound setup pi\` to set it up regardless.
|
|
42
73
|
|
|
43
74
|
1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
|
|
44
75
|
when --api-key is omitted).
|
|
@@ -82,6 +113,7 @@ Examples:
|
|
|
82
113
|
let discoverySucceeded = false;
|
|
83
114
|
let discoveryDomain;
|
|
84
115
|
let skippedTools;
|
|
116
|
+
let piFailed = false;
|
|
85
117
|
let mdmScanKey; // device-owner key resolved from the serial (MDM scope only)
|
|
86
118
|
try {
|
|
87
119
|
// Persist URLs first, then login, then setup — order matters so the
|
|
@@ -120,6 +152,7 @@ Examples:
|
|
|
120
152
|
|
|
121
153
|
console.log('');
|
|
122
154
|
output.info('Step 1/2: Installing MDM tool bundle (all users)');
|
|
155
|
+
const setUpPi = shouldSetUpPi(MDM_TOOLS.pi.label, { allUsers: true });
|
|
123
156
|
const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
|
|
124
157
|
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
125
158
|
});
|
|
@@ -128,6 +161,14 @@ Examples:
|
|
|
128
161
|
// non-zero exit explicit here so onboard never returns 0 on failure.
|
|
129
162
|
if (!ok) { process.exitCode = 1; return; }
|
|
130
163
|
setupSucceeded = true;
|
|
164
|
+
// Pi runs on its own so a failure there cannot cost the device its
|
|
165
|
+
// bundle, its discovery scan or its scheduled run.
|
|
166
|
+
if (setUpPi) {
|
|
167
|
+
const pi = await runMdmSetupAllBundle(adminApiKey, {
|
|
168
|
+
backendUrl, frontendUrl, gatewayUrl, tools: ['pi'],
|
|
169
|
+
});
|
|
170
|
+
piFailed = !pi.ok;
|
|
171
|
+
}
|
|
131
172
|
|
|
132
173
|
console.log('');
|
|
133
174
|
output.info('Step 2/2: Running device discovery');
|
|
@@ -145,6 +186,7 @@ Examples:
|
|
|
145
186
|
|
|
146
187
|
console.log('');
|
|
147
188
|
output.info('Step 1/2: Installing tool bundle');
|
|
189
|
+
const setUpPi = shouldSetUpPi(SETUP_TOOL_MAP.pi.label, { allUsers: false });
|
|
148
190
|
const { ok, skipped } = await runSetupAllBundle(apiKey, {
|
|
149
191
|
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
150
192
|
});
|
|
@@ -153,6 +195,14 @@ Examples:
|
|
|
153
195
|
// non-zero exit explicit here so onboard never returns 0 on failure.
|
|
154
196
|
if (!ok) { process.exitCode = 1; return; }
|
|
155
197
|
setupSucceeded = true;
|
|
198
|
+
// Pi runs on its own so a failure there cannot cost the device its
|
|
199
|
+
// bundle, its discovery scan or its scheduled run.
|
|
200
|
+
if (setUpPi) {
|
|
201
|
+
const pi = await runSetupAllBundle(apiKey, {
|
|
202
|
+
backendUrl, frontendUrl, gatewayUrl, tools: ['pi'],
|
|
203
|
+
});
|
|
204
|
+
piFailed = !pi.ok;
|
|
205
|
+
}
|
|
156
206
|
|
|
157
207
|
console.log('');
|
|
158
208
|
output.info('Step 2/2: Running device discovery');
|
|
@@ -195,6 +245,12 @@ Examples:
|
|
|
195
245
|
}
|
|
196
246
|
|
|
197
247
|
console.log('');
|
|
248
|
+
if (piFailed) {
|
|
249
|
+
// Everything else is done, but a failed setup must NOT report onboarding success.
|
|
250
|
+
output.error(`Onboarding finished, but ${SETUP_TOOL_MAP.pi.label} setup failed (see above). Retry it with: ${piRetryCommand(isMdm)}`);
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
198
254
|
output.success(skippedTools && skippedTools.length
|
|
199
255
|
? 'Onboarding complete — tools managed by MDM were skipped (see above)'
|
|
200
256
|
: 'Onboarding complete');
|
|
@@ -209,20 +265,29 @@ Examples:
|
|
|
209
265
|
// step failed. Retry just the cron: under sudo the scheduled job is a
|
|
210
266
|
// discovery-only scan, so point at `discover --set-cron`; in user
|
|
211
267
|
// scope it re-runs the full onboard (the bundle reinstall is intended).
|
|
212
|
-
console.error(
|
|
268
|
+
console.error(piFailed
|
|
269
|
+
? ' The tool bundle and discovery completed — scheduled-run setup failed.'
|
|
270
|
+
: ' Setup and discovery completed successfully — only scheduled-run setup failed.');
|
|
213
271
|
if (isMdm) {
|
|
214
272
|
console.error(` Re-run with: ${elevated(`unbound onboard --api-key <ADMIN_KEY> --set-cron${suffix}`)}`);
|
|
215
273
|
} else {
|
|
216
274
|
console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
|
|
217
275
|
}
|
|
218
276
|
} else if (setupSucceeded && !discoverySucceeded) {
|
|
219
|
-
console.error(
|
|
277
|
+
console.error(piFailed
|
|
278
|
+
? ' The tool bundle is set up — discovery failed.'
|
|
279
|
+
: ' Tool setup completed successfully — only discovery failed.');
|
|
220
280
|
if (isMdm) {
|
|
221
281
|
console.error(` Re-run onboarding with: ${elevated(`unbound onboard --api-key <ADMIN_KEY>${suffix}`)}`);
|
|
222
282
|
} else {
|
|
223
283
|
console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
|
|
224
284
|
}
|
|
225
285
|
}
|
|
286
|
+
// A later failure must not bury an earlier one: the hints above retry
|
|
287
|
+
// discovery or the schedule, and neither of those retries Pi.
|
|
288
|
+
if (piFailed) {
|
|
289
|
+
console.error(` ${SETUP_TOOL_MAP.pi.label} setup also failed (see above). Retry it with: ${piRetryCommand(isMdm)}`);
|
|
290
|
+
}
|
|
226
291
|
process.exitCode = 1;
|
|
227
292
|
}
|
|
228
293
|
}));
|
package/src/commands/setup.js
CHANGED
|
@@ -1289,12 +1289,13 @@ Examples:
|
|
|
1289
1289
|
}
|
|
1290
1290
|
|
|
1291
1291
|
/**
|
|
1292
|
-
* Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners
|
|
1292
|
+
* Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners,
|
|
1293
|
+
* or just `tools` when the caller has narrowed it.
|
|
1293
1294
|
* Assumes the caller has already ensured the user is logged in.
|
|
1294
1295
|
* Returns true on success, false on failure.
|
|
1295
1296
|
*/
|
|
1296
|
-
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
1297
|
-
const resolvedTools =
|
|
1297
|
+
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false, tools = ALL_TOOLS } = {}) {
|
|
1298
|
+
const resolvedTools = tools.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
|
|
1298
1299
|
if (backfill) {
|
|
1299
1300
|
for (const tool of resolvedTools) {
|
|
1300
1301
|
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
@@ -1315,12 +1316,13 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
1315
1316
|
}
|
|
1316
1317
|
|
|
1317
1318
|
/**
|
|
1318
|
-
* Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners
|
|
1319
|
+
* Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners,
|
|
1320
|
+
* or just `tools` when the caller has narrowed it.
|
|
1319
1321
|
* Caller must ensure the process is running as root.
|
|
1320
1322
|
* Returns true on success, false on failure.
|
|
1321
1323
|
*/
|
|
1322
|
-
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
1323
|
-
const resolvedTools =
|
|
1324
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false, tools = MDM_ALL_TOOLS } = {}) {
|
|
1325
|
+
const resolvedTools = tools.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
1324
1326
|
if (backfill) {
|
|
1325
1327
|
for (const tool of resolvedTools) {
|
|
1326
1328
|
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Detects whether an AI coding tool is actually present on this device, so
|
|
2
|
+
// `unbound onboard` can set a tool up only where it is in use. Pi is the only
|
|
3
|
+
// tool gated on detection today; every other tool has no signals here and is
|
|
4
|
+
// always treated as installed.
|
|
5
|
+
//
|
|
6
|
+
// A tool counts as installed when ANY of its signals is found: its CLI binary,
|
|
7
|
+
// or a file the tool writes for itself. Detection deliberately leans towards
|
|
8
|
+
// "installed": a false positive only installs an unused extension, while a
|
|
9
|
+
// false negative leaves a tool ungoverned. For the same reason detection
|
|
10
|
+
// throws, instead of answering "not installed", when it found nothing but
|
|
11
|
+
// could not inspect some place it needed to.
|
|
12
|
+
//
|
|
13
|
+
// The one thing a signal must never be is a path Unbound's own setup scripts
|
|
14
|
+
// create (~/.pi/agent/extensions/unbound/). That would make pi look installed
|
|
15
|
+
// on any device that was onboarded before, so the markers below are files only
|
|
16
|
+
// pi itself writes.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const childProcess = require('child_process');
|
|
22
|
+
|
|
23
|
+
// Where a per-user CLI lands, relative to a home. PATH alone is not enough:
|
|
24
|
+
// under sudo and under the scheduled run it is a minimal system PATH that
|
|
25
|
+
// lacks these. `.pi/agent/bin` is where pi's own installer puts its binary.
|
|
26
|
+
const HOME_BIN_DIRS = [
|
|
27
|
+
'.pi/agent/bin',
|
|
28
|
+
'.local/bin',
|
|
29
|
+
'.bun/bin',
|
|
30
|
+
'.npm-global/bin',
|
|
31
|
+
'.volta/bin',
|
|
32
|
+
'.yarn/bin',
|
|
33
|
+
'AppData/Roaming/npm',
|
|
34
|
+
'AppData/Local/Microsoft/WinGet/Links',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const TOOL_SIGNALS = {
|
|
38
|
+
'pi': {
|
|
39
|
+
bins: ['pi'],
|
|
40
|
+
markers: ['.pi/agent/auth.json', '.pi/agent/sessions'],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Directories under the users root that are not accounts. Linux has no entry:
|
|
45
|
+
// a user named Shared or Guest there is a real one.
|
|
46
|
+
const SKIP_HOME_NAMES = {
|
|
47
|
+
darwin: ['Shared', 'Guest'],
|
|
48
|
+
win32: ['Public', 'Default', 'Default User', 'All Users'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// "Not there" and "could not look" are different answers. Only the first means
|
|
52
|
+
// a signal is absent; anything else (no permission, an I/O error) is kept in
|
|
53
|
+
// `errors` so the caller does not report a tool it could not rule out as
|
|
54
|
+
// missing.
|
|
55
|
+
const ABSENT = new Set(['ENOENT', 'ENOTDIR']);
|
|
56
|
+
|
|
57
|
+
function stat(p, errors) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.statSync(p);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!ABSENT.has(err.code)) errors.push(err);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listDir(p, errors) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.readdirSync(p);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!ABSENT.has(err.code)) errors.push(err);
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLink(p) {
|
|
76
|
+
try {
|
|
77
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pathDirs() {
|
|
84
|
+
return (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function machineBinDirs(platform) {
|
|
88
|
+
if (platform === 'win32') return [];
|
|
89
|
+
return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/snap/bin'];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dirs the OS package manager owns, where a bare tool name may be another
|
|
93
|
+
// program: Debian and Ubuntu ship a digits-of-pi calculator at /usr/bin/pi.
|
|
94
|
+
function osPackageBinDirs(platform) {
|
|
95
|
+
return platform === 'win32' ? [] : ['/usr/bin', '/bin'];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function realDir(dir) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.realpathSync(dir);
|
|
101
|
+
} catch {
|
|
102
|
+
return path.resolve(dir);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const HOME_PREFIXES = { darwin: '/Users/', linux: '/home/' };
|
|
107
|
+
const ACCOUNT_LOOKUP_TIMEOUT_MS = 30000;
|
|
108
|
+
|
|
109
|
+
// Home directories from the account database. A listing of the users root
|
|
110
|
+
// alone misses a home that is not its direct child, such as /home/DOMAIN/alice.
|
|
111
|
+
// The MDM setup scripts are Python and find their users with pwd.getpwall(),
|
|
112
|
+
// so the same interpreter is asked the same question: detection then looks in
|
|
113
|
+
// every home a setup script would install into. Windows has no such database;
|
|
114
|
+
// its profile directories are the list.
|
|
115
|
+
//
|
|
116
|
+
// Two ways of getting no answer are told apart:
|
|
117
|
+
// - Python is missing, or getpwall() itself fails. A setup script fails the
|
|
118
|
+
// same way, so it could not install into those homes either. Nothing
|
|
119
|
+
// reachable went unseen, and the users root listing is the whole answer.
|
|
120
|
+
// - The lookup timed out or printed something unreadable. The database may be
|
|
121
|
+
// slow rather than down, and a setup script, which waits far longer, could
|
|
122
|
+
// still list those homes. That is kept in `errors` as a place that could
|
|
123
|
+
// not be inspected.
|
|
124
|
+
function accountHomes(platform, errors) {
|
|
125
|
+
const prefix = HOME_PREFIXES[platform];
|
|
126
|
+
if (!prefix) return [];
|
|
127
|
+
const script = 'import json, pwd; print(json.dumps([e.pw_dir for e in pwd.getpwall()]))';
|
|
128
|
+
for (const python of ['python3', 'python']) {
|
|
129
|
+
let out;
|
|
130
|
+
try {
|
|
131
|
+
out = childProcess.execFileSync(python, ['-c', script], {
|
|
132
|
+
encoding: 'utf8', timeout: ACCOUNT_LOOKUP_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err.code === 'ETIMEDOUT') {
|
|
136
|
+
errors.push(err);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(out).filter(home => typeof home === 'string' && home.startsWith(prefix));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
errors.push(err);
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Every real user's home, for the sudo/MDM scope where the tools belong to
|
|
152
|
+
// users other than the one running the command: every home the account
|
|
153
|
+
// database names, and every directory under the users root for accounts the
|
|
154
|
+
// database does not enumerate.
|
|
155
|
+
function allUserHomes(platform, { errors = [], root, accounts } = {}) {
|
|
156
|
+
const usersRoot = root || (platform === 'win32'
|
|
157
|
+
? path.join(process.env.SystemDrive || 'C:', path.sep, 'Users')
|
|
158
|
+
: platform === 'darwin' ? '/Users' : '/home');
|
|
159
|
+
const skip = new Set(SKIP_HOME_NAMES[platform] || []);
|
|
160
|
+
const candidates = [
|
|
161
|
+
...(accounts || accountHomes(platform, errors)),
|
|
162
|
+
...listDir(usersRoot, errors).filter(name => !name.startsWith('.')).map(name => path.join(usersRoot, name)),
|
|
163
|
+
];
|
|
164
|
+
const homes = new Set([os.homedir()]);
|
|
165
|
+
for (const home of candidates) {
|
|
166
|
+
if (skip.has(path.basename(home))) continue;
|
|
167
|
+
const found = stat(home, errors);
|
|
168
|
+
if (found && found.isDirectory()) homes.add(home);
|
|
169
|
+
}
|
|
170
|
+
return [...homes];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function binDirsFor(ctx) {
|
|
174
|
+
const dirs = [...ctx.pathDirs, ...ctx.machineBinDirs];
|
|
175
|
+
for (const home of ctx.homes) {
|
|
176
|
+
for (const rel of HOME_BIN_DIRS) dirs.push(path.join(home, rel));
|
|
177
|
+
// nvm keeps one bin dir per installed Node version.
|
|
178
|
+
const nvm = path.join(home, '.nvm', 'versions', 'node');
|
|
179
|
+
for (const version of listDir(nvm, ctx.errors)) dirs.push(path.join(nvm, version, 'bin'));
|
|
180
|
+
}
|
|
181
|
+
return dirs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hasBinary(name, ctx) {
|
|
185
|
+
const names = ctx.platform === 'win32'
|
|
186
|
+
? [`${name}.exe`, `${name}.cmd`, `${name}.ps1`, name]
|
|
187
|
+
: [name];
|
|
188
|
+
return binDirsFor(ctx).some((dir) => {
|
|
189
|
+
// In a dir the OS package manager owns, a regular file with the tool's
|
|
190
|
+
// name is the OS's own program. npm links a tool in, so there only a
|
|
191
|
+
// symlink counts.
|
|
192
|
+
const linksOnly = ctx.osPackageBinDirs.has(realDir(dir));
|
|
193
|
+
return names.some((n) => {
|
|
194
|
+
const p = path.join(dir, n);
|
|
195
|
+
if (!stat(p, ctx.errors)) return false;
|
|
196
|
+
return !linksOnly || isLink(p);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isInstalled(toolName, ctx) {
|
|
202
|
+
const signals = TOOL_SIGNALS[toolName];
|
|
203
|
+
// An unknown tool has no way to be ruled out, so it is treated as installed.
|
|
204
|
+
if (!signals) return true;
|
|
205
|
+
if ((signals.bins || []).some(bin => hasBinary(bin, ctx))) return true;
|
|
206
|
+
if ((signals.markers || []).some(rel => ctx.homes.some(home => stat(path.join(home, rel), ctx.errors)))) return true;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Splits `toolNames` into those present on this device and those that are not.
|
|
212
|
+
* `allUsers` checks every user's home (sudo/MDM scope) instead of only the
|
|
213
|
+
* current user's. The remaining options exist so tests can point detection at
|
|
214
|
+
* a fixture tree instead of the real machine.
|
|
215
|
+
*
|
|
216
|
+
* Throws when a tool was not found but some place could not be inspected, so
|
|
217
|
+
* the caller can set the tool up instead of treating it as absent.
|
|
218
|
+
*/
|
|
219
|
+
function detectInstalledTools(toolNames, { allUsers = false, platform = process.platform, ...overrides } = {}) {
|
|
220
|
+
const errors = [];
|
|
221
|
+
const ctx = {
|
|
222
|
+
platform,
|
|
223
|
+
errors,
|
|
224
|
+
homes: overrides.homes || (allUsers ? allUserHomes(platform, { errors }) : [os.homedir()]),
|
|
225
|
+
pathDirs: overrides.pathDirs || pathDirs(),
|
|
226
|
+
machineBinDirs: overrides.machineBinDirs || machineBinDirs(platform),
|
|
227
|
+
osPackageBinDirs: new Set((overrides.osPackageBinDirs || osPackageBinDirs(platform)).map(realDir)),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const detected = [];
|
|
231
|
+
const missing = [];
|
|
232
|
+
for (const name of toolNames) {
|
|
233
|
+
(isInstalled(name, ctx) ? detected : missing).push(name);
|
|
234
|
+
}
|
|
235
|
+
if (missing.length && errors.length) throw errors[0];
|
|
236
|
+
return { detected, missing };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { detectInstalledTools, allUserHomes, accountHomes, TOOL_SIGNALS };
|
|
@@ -27,6 +27,9 @@ function loadOnboard({ isRoot, setupOk, reportThrows }) {
|
|
|
27
27
|
const setupReport = require('../src/setup-report');
|
|
28
28
|
|
|
29
29
|
setup.hasRootPrivileges = () => isRoot;
|
|
30
|
+
// Detection and the Pi step are covered in tool-detect.test.js; here pi is absent,
|
|
31
|
+
// so the bundle is the only setup call.
|
|
32
|
+
require('../src/toolDetect').detectInstalledTools = (names) => ({ detected: [], missing: names });
|
|
30
33
|
// Mirror runBatch's real behavior: on failure it sets exitCode and (best-effort)
|
|
31
34
|
// reports, then returns { ok: false }. We model both the report call and the
|
|
32
35
|
// exitCode side effect so the test exercises onboard's own handling.
|
|
@@ -57,6 +57,9 @@ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd', domain,
|
|
|
57
57
|
const scheduled = require('../src/scheduled');
|
|
58
58
|
|
|
59
59
|
setup.hasRootPrivileges = () => isRoot;
|
|
60
|
+
// Detection and the Pi step are covered in tool-detect.test.js; here pi is absent,
|
|
61
|
+
// so the bundle is the only setup call.
|
|
62
|
+
require('../src/toolDetect').detectInstalledTools = (names) => ({ detected: [], missing: names });
|
|
60
63
|
setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
|
|
61
64
|
setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
|
|
62
65
|
discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };
|
|
@@ -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
|
+
});
|