unbound-cli 1.16.18 → 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 +15 -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/setup-pi.test.js +206 -0
- package/test/setup-tables.test.js +132 -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
|
@@ -36,6 +36,7 @@ const SETUP_TOOLS = [
|
|
|
36
36
|
{ label: 'Gemini CLI', value: 'gemini', script: 'gemini-cli/gateway/setup.py' },
|
|
37
37
|
{ label: 'Codex \u2014 subscription (hooks)', value: 'codex-sub', script: 'codex/hooks/setup.py', group: 'codex' },
|
|
38
38
|
{ label: 'Codex \u2014 gateway (gateway)', value: 'codex-gw', script: 'codex/gateway/setup.py', group: 'codex' },
|
|
39
|
+
{ label: 'Pi Coding Agent', value: 'pi', script: 'pi/setup.py' },
|
|
39
40
|
];
|
|
40
41
|
|
|
41
42
|
// Labels drop the `(subscription)` suffix on the default mode and keep
|
|
@@ -49,6 +50,7 @@ const MDM_TOOLS = {
|
|
|
49
50
|
'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
|
|
50
51
|
'codex-subscription': { label: 'Codex', script: 'codex/hooks/mdm/setup.py' },
|
|
51
52
|
'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
|
|
53
|
+
'pi': { label: 'Pi Coding Agent', script: 'pi/mdm/setup.py' },
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
// Default MDM tools for `sudo unbound onboard` (subscription mode for Claude Code/Codex since only one can be active)
|
|
@@ -73,6 +75,7 @@ const SETUP_TOOL_MAP = {
|
|
|
73
75
|
'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
|
|
74
76
|
'codex-subscription': { label: 'Codex', script: 'codex/hooks/setup.py' },
|
|
75
77
|
'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
|
|
78
|
+
'pi': { label: 'Pi Coding Agent', script: 'pi/setup.py' },
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -763,6 +766,7 @@ Available tools:
|
|
|
763
766
|
claude-code Claude Code (use --subscription or --gateway)
|
|
764
767
|
gemini-cli Gemini CLI
|
|
765
768
|
codex Codex (use --subscription or --gateway)
|
|
769
|
+
pi Pi Coding Agent
|
|
766
770
|
roo-code Roo Code (shows config values)
|
|
767
771
|
cline Cline (shows config values)
|
|
768
772
|
kilo-code Kilo Code (shows config values)
|
|
@@ -782,6 +786,7 @@ Examples:
|
|
|
782
786
|
$ unbound setup claude-code --gateway Claude Code gateway mode
|
|
783
787
|
$ unbound setup claude-code --subscription Claude Code hooks only
|
|
784
788
|
$ unbound setup codex --gateway Codex gateway mode
|
|
789
|
+
$ unbound setup pi Set up Pi Coding Agent
|
|
785
790
|
|
|
786
791
|
Install the default bundle (Cursor + Copilot + Augment + Claude Code hooks + Codex hooks):
|
|
787
792
|
$ unbound setup --all Set up the default bundle
|
|
@@ -1284,12 +1289,13 @@ Examples:
|
|
|
1284
1289
|
}
|
|
1285
1290
|
|
|
1286
1291
|
/**
|
|
1287
|
-
* 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.
|
|
1288
1294
|
* Assumes the caller has already ensured the user is logged in.
|
|
1289
1295
|
* Returns true on success, false on failure.
|
|
1290
1296
|
*/
|
|
1291
|
-
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
1292
|
-
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] }));
|
|
1293
1299
|
if (backfill) {
|
|
1294
1300
|
for (const tool of resolvedTools) {
|
|
1295
1301
|
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
@@ -1310,12 +1316,13 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
1310
1316
|
}
|
|
1311
1317
|
|
|
1312
1318
|
/**
|
|
1313
|
-
* 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.
|
|
1314
1321
|
* Caller must ensure the process is running as root.
|
|
1315
1322
|
* Returns true on success, false on failure.
|
|
1316
1323
|
*/
|
|
1317
|
-
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
1318
|
-
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] }));
|
|
1319
1326
|
if (backfill) {
|
|
1320
1327
|
for (const tool of resolvedTools) {
|
|
1321
1328
|
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
@@ -1341,6 +1348,8 @@ module.exports = {
|
|
|
1341
1348
|
hasRootPrivileges,
|
|
1342
1349
|
ALL_TOOLS,
|
|
1343
1350
|
MDM_ALL_TOOLS,
|
|
1351
|
+
SETUP_TOOL_MAP,
|
|
1352
|
+
MDM_TOOLS,
|
|
1344
1353
|
buildScriptArgs,
|
|
1345
1354
|
configDirForScript,
|
|
1346
1355
|
scriptSupportsBackfill,
|
|
@@ -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,206 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
SETUP_TOOL_MAP,
|
|
7
|
+
MDM_TOOLS,
|
|
8
|
+
ALL_TOOLS,
|
|
9
|
+
MDM_ALL_TOOLS,
|
|
10
|
+
resolveSetupAllTools,
|
|
11
|
+
buildScriptArgs,
|
|
12
|
+
configDirForScript,
|
|
13
|
+
scriptSupportsBackfill,
|
|
14
|
+
} = require('../src/commands/setup');
|
|
15
|
+
|
|
16
|
+
const SETUP_SOURCE_PATH = path.join(__dirname, '..', 'src', 'commands', 'setup.js');
|
|
17
|
+
const SETUP_SOURCE = fs.readFileSync(SETUP_SOURCE_PATH, 'utf8');
|
|
18
|
+
|
|
19
|
+
// The 10-00 harness in test/setup-tables.test.js is a test file, not a module: it has
|
|
20
|
+
// no module.exports, and requiring it would re-run its five tests inside this file.
|
|
21
|
+
// The five-check shape below is therefore the same contract re-stated locally rather
|
|
22
|
+
// than imported — test/setup-tables.test.js stays byte-identical.
|
|
23
|
+
function sourceArrayBlock(name) {
|
|
24
|
+
const start = SETUP_SOURCE.indexOf(`const ${name} = [`);
|
|
25
|
+
assert.notEqual(start, -1, `${name} not found in setup.js`);
|
|
26
|
+
const end = SETUP_SOURCE.indexOf('];', start);
|
|
27
|
+
assert.notEqual(end, -1, `${name} is not terminated`);
|
|
28
|
+
return SETUP_SOURCE.slice(start, end);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The "Available tools:" block ends at the first blank line, before the
|
|
32
|
+
// "For multi-tool setup" section that lists the explicit mode names.
|
|
33
|
+
function helpToolLines() {
|
|
34
|
+
const start = SETUP_SOURCE.indexOf('Available tools:');
|
|
35
|
+
assert.notEqual(start, -1, 'the help text has no "Available tools:" block');
|
|
36
|
+
const block = SETUP_SOURCE.slice(start).split('\n\n')[0];
|
|
37
|
+
return block.split('\n').slice(1).filter((line) => line.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HELP_TOOL_LINES = helpToolLines();
|
|
41
|
+
const HELP_TOOL_NAMES = HELP_TOOL_LINES.map((line) => line.trim().split(/\s+/)[0]);
|
|
42
|
+
const PICKER_BLOCK = sourceArrayBlock('SETUP_TOOLS');
|
|
43
|
+
|
|
44
|
+
// SETUP_ALL_TOOLS and MDM_SETUP_ALL_TOOLS are deliberately NOT exported — keeping them
|
|
45
|
+
// unexported is what holds this plan's setup.js diff at five lines — so their membership
|
|
46
|
+
// is read off the array literals, the same seam 10-00 already uses for the help text.
|
|
47
|
+
function sourceStringListBlock(name) {
|
|
48
|
+
const match = SETUP_SOURCE.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`));
|
|
49
|
+
assert.ok(match, `${name} not found as an array literal in setup.js`);
|
|
50
|
+
return match[1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BUNDLES = {
|
|
54
|
+
// exported, asserted through the real object
|
|
55
|
+
ALL_TOOLS: (name) => ALL_TOOLS.includes(name),
|
|
56
|
+
MDM_ALL_TOOLS: (name) => MDM_ALL_TOOLS.includes(name),
|
|
57
|
+
// unexported, asserted through the source literal
|
|
58
|
+
SETUP_ALL_TOOLS: (name) => sourceStringListBlock('SETUP_ALL_TOOLS').includes(`'${name}'`),
|
|
59
|
+
MDM_SETUP_ALL_TOOLS: (name) => sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes(`'${name}'`),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function checkToolWired(name, spec = {}) {
|
|
63
|
+
const mdmKey = spec.mdmKey || name;
|
|
64
|
+
const entry = SETUP_TOOL_MAP[name];
|
|
65
|
+
const mdmEntry = MDM_TOOLS[mdmKey];
|
|
66
|
+
return {
|
|
67
|
+
inSetupMap: Boolean(entry) && entry.script === spec.script,
|
|
68
|
+
inMdmTools: Boolean(mdmEntry) && mdmEntry.script === spec.mdmScript,
|
|
69
|
+
inPicker: PICKER_BLOCK.includes(`value: '${name}'`),
|
|
70
|
+
inHelp: HELP_TOOL_NAMES.includes(name),
|
|
71
|
+
inSetupAllClear: resolveSetupAllTools(true).includes(name),
|
|
72
|
+
bundles: Object.keys(BUNDLES).filter((bundle) => BUNDLES[bundle](name)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertToolWired(name, spec) {
|
|
77
|
+
const got = checkToolWired(name, spec);
|
|
78
|
+
assert.ok(got.inSetupMap, `${name}: SETUP_TOOL_MAP entry missing or script !== ${spec.script}`);
|
|
79
|
+
assert.ok(got.inMdmTools, `${name}: MDM_TOOLS entry missing or script !== ${spec.mdmScript}`);
|
|
80
|
+
assert.ok(got.inPicker, `${name}: absent from the interactive SETUP_TOOLS picker`);
|
|
81
|
+
assert.ok(got.inHelp, `${name}: absent from the "Available tools:" help block`);
|
|
82
|
+
assert.ok(got.inSetupAllClear, `${name}: absent from resolveSetupAllTools(true)`);
|
|
83
|
+
assert.deepEqual(got.bundles.sort(), [...spec.bundles].sort(), `${name}: bundle membership drifted`);
|
|
84
|
+
return got;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PI_SPEC = {
|
|
88
|
+
script: 'pi/setup.py',
|
|
89
|
+
mdmScript: 'pi/mdm/setup.py',
|
|
90
|
+
bundles: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// INST-01/INST-02: `unbound setup pi` and `sudo unbound setup pi` resolve to the two
|
|
94
|
+
// Python installers through the dispatcher every other tool already uses.
|
|
95
|
+
test('pi is wired across the four setup tables and in no default bundle', () => {
|
|
96
|
+
const got = assertToolWired('pi', PI_SPEC);
|
|
97
|
+
assert.deepEqual(got.bundles, []);
|
|
98
|
+
assert.equal(SETUP_TOOL_MAP.pi.label, 'Pi Coding Agent');
|
|
99
|
+
assert.equal(SETUP_TOOL_MAP.pi.script, 'pi/setup.py');
|
|
100
|
+
assert.equal(MDM_TOOLS.pi.label, 'Pi Coding Agent');
|
|
101
|
+
assert.equal(MDM_TOOLS.pi.script, 'pi/mdm/setup.py');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// No `group` key: pi has a single mode, so the picker must not offer it as a
|
|
105
|
+
// subscription/gateway pair the way claude-code and codex are grouped.
|
|
106
|
+
test('the interactive picker entry for pi has a script, a label and no group', () => {
|
|
107
|
+
const line = PICKER_BLOCK.split('\n').find((l) => l.includes("value: 'pi'"));
|
|
108
|
+
assert.ok(line, `no SETUP_TOOLS entry with value: 'pi'\n${PICKER_BLOCK}`);
|
|
109
|
+
assert.ok(line.includes("label: 'Pi Coding Agent'"), line);
|
|
110
|
+
assert.ok(line.includes("script: 'pi/setup.py'"), line);
|
|
111
|
+
assert.ok(!line.includes('group:'), `pi has one mode, so the picker entry must carry no group: ${line}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// `unbound setup --all` and `unbound onboard` print the bundles verbatim, so pi must
|
|
115
|
+
// stay out of all four — but `--all --clear` must still sweep it, because
|
|
116
|
+
// resolveSetupAllTools(true) returns every SETUP_TOOL_MAP key.
|
|
117
|
+
test('pi is absent from each of the four default bundles, asserted one by one', () => {
|
|
118
|
+
assert.equal(ALL_TOOLS.includes('pi'), false, 'pi leaked into ALL_TOOLS');
|
|
119
|
+
assert.equal(MDM_ALL_TOOLS.includes('pi'), false, 'pi leaked into MDM_ALL_TOOLS');
|
|
120
|
+
assert.equal(sourceStringListBlock('SETUP_ALL_TOOLS').includes("'pi'"), false,
|
|
121
|
+
'pi leaked into the SETUP_ALL_TOOLS literal');
|
|
122
|
+
assert.equal(sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes("'pi'"), false,
|
|
123
|
+
'pi leaked into the MDM_SETUP_ALL_TOOLS literal');
|
|
124
|
+
// the negative half: the regex reader is not vacuously returning nothing
|
|
125
|
+
assert.equal(sourceStringListBlock('SETUP_ALL_TOOLS').includes("'cursor'"), true);
|
|
126
|
+
assert.equal(sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes("'cursor'"), true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('resolveSetupAllTools sweeps pi on --all --clear but never installs it via --all', () => {
|
|
130
|
+
assert.ok(resolveSetupAllTools(true).includes('pi'), resolveSetupAllTools(true));
|
|
131
|
+
assert.equal(resolveSetupAllTools(false).includes('pi'), false, resolveSetupAllTools(false));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the help text lists pi as "Pi Coding Agent" and shows an `unbound setup pi` example', () => {
|
|
135
|
+
const piHelpLine = HELP_TOOL_LINES.find((l) => l.trim().startsWith('pi '));
|
|
136
|
+
assert.ok(piHelpLine, `no pi line in the "Available tools:" block\n${HELP_TOOL_LINES.join('\n')}`);
|
|
137
|
+
assert.match(piHelpLine, /^\s*pi\s+Pi Coding Agent\s*$/);
|
|
138
|
+
const examples = SETUP_SOURCE.slice(SETUP_SOURCE.indexOf('Examples:'));
|
|
139
|
+
assert.match(examples, /\$ unbound setup pi\s+Set up Pi Coding Agent/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// buildScriptArgs takes no tool argument (setup.js:256), so pi adds no argv behaviour.
|
|
143
|
+
// This is a cross-check of the generic flag shape — test/setup-args.test.js:126-137
|
|
144
|
+
// already owns the primary coverage — asserted once against pi's own call shape.
|
|
145
|
+
test('buildScriptArgs stays tool-agnostic: --domain non-MDM, --frontend-url under MDM, --clear keyless', () => {
|
|
146
|
+
const user = buildScriptArgs('k-123', { backendUrl: 'https://b', gatewayUrl: 'https://g', frontendUrl: 'https://f' });
|
|
147
|
+
assert.match(user, /--api-key /);
|
|
148
|
+
assert.match(user, /--domain /);
|
|
149
|
+
assert.ok(!user.includes('--frontend-url'), user);
|
|
150
|
+
|
|
151
|
+
const mdm = buildScriptArgs('k-123', { frontendUrl: 'https://f', mdm: true });
|
|
152
|
+
assert.match(mdm, /--frontend-url /);
|
|
153
|
+
assert.ok(!mdm.includes('--domain'), mdm);
|
|
154
|
+
|
|
155
|
+
// --clear runs no auth in the Python installers, so no key is present and the
|
|
156
|
+
// literal string "undefined" must never reach the argv.
|
|
157
|
+
const cleared = buildScriptArgs(undefined, { clear: true, backendUrl: 'https://b' });
|
|
158
|
+
assert.match(cleared, /--clear/);
|
|
159
|
+
assert.ok(!cleared.includes('--api-key'), cleared);
|
|
160
|
+
assert.ok(!cleared.includes('undefined'), cleared);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// configDirForScript returns undefined for any script outside this set, and also for a
|
|
164
|
+
// member when CLAUDE_CONFIG_DIR is unset — so set membership, not the return value, is
|
|
165
|
+
// the positive control that keeps the pi assertion from passing vacuously.
|
|
166
|
+
const CLAUDE_CONFIG_DIR_SOURCE = SETUP_SOURCE.slice(
|
|
167
|
+
SETUP_SOURCE.indexOf('const CLAUDE_CONFIG_DIR_SCRIPTS'),
|
|
168
|
+
SETUP_SOURCE.indexOf(']);', SETUP_SOURCE.indexOf('const CLAUDE_CONFIG_DIR_SCRIPTS')),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
test('pi honours neither CLAUDE_CONFIG_DIR nor --backfill', () => {
|
|
172
|
+
assert.ok(!configDirForScript('pi/setup.py'), String(configDirForScript('pi/setup.py')));
|
|
173
|
+
assert.equal(scriptSupportsBackfill('pi/setup.py'), false);
|
|
174
|
+
assert.equal(scriptSupportsBackfill('pi/mdm/setup.py'), false);
|
|
175
|
+
// positive controls
|
|
176
|
+
assert.equal(scriptSupportsBackfill('claude-code/hooks/setup.py'), true);
|
|
177
|
+
assert.ok(CLAUDE_CONFIG_DIR_SOURCE.includes("'claude-code/hooks/setup.py'"),
|
|
178
|
+
'the CLAUDE_CONFIG_DIR set no longer lists the Claude hooks installer');
|
|
179
|
+
assert.ok(!CLAUDE_CONFIG_DIR_SOURCE.includes("'pi/setup.py'"),
|
|
180
|
+
'pi must not be added to CLAUDE_CONFIG_DIR_SCRIPTS');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// T-10-01: integrity is verified once, in pi/setup.py, against the committed sidecar.
|
|
184
|
+
// A second hash path in JavaScript could diverge from the Python one.
|
|
185
|
+
test('the CLI gains no hashing path — integrity stays in Python', () => {
|
|
186
|
+
assert.equal(/createHash|sha256/i.test(SETUP_SOURCE), false,
|
|
187
|
+
'setup.js must contain no hashing: downloadToFile checks status, redirects, wx-open and length only');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// T-10-13: the release-version bump is validated by .github/workflows/version-check.yml,
|
|
191
|
+
// which diffs package.json against a real checkout of `main` on every PR. It is
|
|
192
|
+
// deliberately NOT asserted here: this suite has to pass on any checkout, and a
|
|
193
|
+
// version assertion would couple it to mutable Git state — it would fail on `main`
|
|
194
|
+
// immediately after this PR merges (ours would then equal main's) and would need
|
|
195
|
+
// editing on every future bump. Release state belongs in the release workflow.
|
|
196
|
+
|
|
197
|
+
// The Edit-tool hazard from 10-VALIDATION: setup.js:33-38 holds four real two-character
|
|
198
|
+
// — escapes. An edit near them must not normalise them into literal em-dashes.
|
|
199
|
+
// Scoped to the picker block on purpose: literal em-dashes are used freely in setup.js
|
|
200
|
+
// comments, so only the four string-literal escapes inside SETUP_TOOLS are asserted.
|
|
201
|
+
test('the four \\u2014 escapes in the picker labels survive byte-for-byte', () => {
|
|
202
|
+
assert.equal((SETUP_SOURCE.match(/\\u2014/g) || []).length, 4, 'the \\u2014 escape count changed');
|
|
203
|
+
assert.equal((PICKER_BLOCK.match(/\\u2014/g) || []).length, 4, 'a picker label escape was lost');
|
|
204
|
+
assert.equal(PICKER_BLOCK.includes('—'), false,
|
|
205
|
+
'a literal em-dash appeared inside SETUP_TOOLS: an escape was normalised');
|
|
206
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
SETUP_TOOL_MAP,
|
|
7
|
+
MDM_TOOLS,
|
|
8
|
+
ALL_TOOLS,
|
|
9
|
+
MDM_ALL_TOOLS,
|
|
10
|
+
resolveSetupAllTools,
|
|
11
|
+
} = require('../src/commands/setup');
|
|
12
|
+
|
|
13
|
+
const SETUP_SOURCE_PATH = path.join(__dirname, '..', 'src', 'commands', 'setup.js');
|
|
14
|
+
const SETUP_SOURCE = fs.readFileSync(SETUP_SOURCE_PATH, 'utf8');
|
|
15
|
+
|
|
16
|
+
// SETUP_TOOLS (the interactive picker) and MDM_SETUP_ALL_TOOLS are not exported and
|
|
17
|
+
// the help text is built inline by addHelpText, so all three are read from source.
|
|
18
|
+
function sourceArrayBlock(name) {
|
|
19
|
+
const start = SETUP_SOURCE.indexOf(`const ${name} = [`);
|
|
20
|
+
assert.notEqual(start, -1, `${name} not found in setup.js`);
|
|
21
|
+
const end = SETUP_SOURCE.indexOf('];', start);
|
|
22
|
+
assert.notEqual(end, -1, `${name} is not terminated`);
|
|
23
|
+
return SETUP_SOURCE.slice(start, end);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The "Available tools:" block ends at the first blank line, before the
|
|
27
|
+
// "For multi-tool setup" section that lists the explicit mode names.
|
|
28
|
+
function helpToolLines() {
|
|
29
|
+
const start = SETUP_SOURCE.indexOf('Available tools:');
|
|
30
|
+
assert.notEqual(start, -1, 'the help text has no "Available tools:" block');
|
|
31
|
+
const block = SETUP_SOURCE.slice(start).split('\n\n')[0];
|
|
32
|
+
return block.split('\n').slice(1).filter((line) => line.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HELP_TOOL_NAMES = helpToolLines().map((line) => line.trim().split(/\s+/)[0]);
|
|
36
|
+
const PICKER_BLOCK = sourceArrayBlock('SETUP_TOOLS');
|
|
37
|
+
const MDM_SETUP_ALL_TOOLS_BLOCK = sourceArrayBlock('MDM_SETUP_ALL_TOOLS');
|
|
38
|
+
|
|
39
|
+
// Every bundle a tool can be in. `pi` must stay out of all four (RESEARCH A1) while
|
|
40
|
+
// still appearing in resolveSetupAllTools(true), so membership is asserted by name.
|
|
41
|
+
const BUNDLES = {
|
|
42
|
+
SETUP_ALL_TOOLS: (name) => resolveSetupAllTools(false).includes(name),
|
|
43
|
+
ALL_TOOLS: (name) => ALL_TOOLS.includes(name),
|
|
44
|
+
MDM_ALL_TOOLS: (name) => MDM_ALL_TOOLS.includes(name),
|
|
45
|
+
MDM_SETUP_ALL_TOOLS: (name) => MDM_SETUP_ALL_TOOLS_BLOCK.includes(`'${name}'`),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// The five wiring facts a `unbound setup <tool>` entry needs, as booleans, so the
|
|
49
|
+
// negative case can prove each one individually rather than trusting a thrown error.
|
|
50
|
+
function checkToolWired(name, spec = {}) {
|
|
51
|
+
const mdmKey = spec.mdmKey || name;
|
|
52
|
+
const entry = SETUP_TOOL_MAP[name];
|
|
53
|
+
const mdmEntry = MDM_TOOLS[mdmKey];
|
|
54
|
+
return {
|
|
55
|
+
inSetupMap: Boolean(entry) && entry.script === spec.script,
|
|
56
|
+
inMdmTools: Boolean(mdmEntry) && mdmEntry.script === spec.mdmScript,
|
|
57
|
+
inPicker: PICKER_BLOCK.includes(`value: '${name}'`),
|
|
58
|
+
inHelp: HELP_TOOL_NAMES.includes(name),
|
|
59
|
+
inSetupAllClear: resolveSetupAllTools(true).includes(name),
|
|
60
|
+
bundles: Object.keys(BUNDLES).filter((bundle) => BUNDLES[bundle](name)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertToolWired(name, spec) {
|
|
65
|
+
const got = checkToolWired(name, spec);
|
|
66
|
+
assert.ok(got.inSetupMap, `${name}: SETUP_TOOL_MAP entry missing or script !== ${spec.script}`);
|
|
67
|
+
assert.ok(got.inMdmTools, `${name}: MDM_TOOLS entry missing or script !== ${spec.mdmScript}`);
|
|
68
|
+
assert.ok(got.inPicker, `${name}: absent from the interactive SETUP_TOOLS picker`);
|
|
69
|
+
assert.ok(got.inHelp, `${name}: absent from the "Available tools:" help block`);
|
|
70
|
+
assert.ok(got.inSetupAllClear, `${name}: absent from resolveSetupAllTools(true)`);
|
|
71
|
+
assert.deepEqual(got.bundles.sort(), [...spec.bundles].sort(),
|
|
72
|
+
`${name}: bundle membership drifted`);
|
|
73
|
+
return got;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The maps have to be reachable at all before any assertion about them means anything.
|
|
77
|
+
test('setup.js exports SETUP_TOOL_MAP and MDM_TOOLS as plain objects', () => {
|
|
78
|
+
assert.equal(typeof SETUP_TOOL_MAP, 'object');
|
|
79
|
+
assert.equal(typeof MDM_TOOLS, 'object');
|
|
80
|
+
assert.ok(Object.keys(SETUP_TOOL_MAP).length >= 8, Object.keys(SETUP_TOOL_MAP));
|
|
81
|
+
assert.ok(Object.keys(MDM_TOOLS).length >= 8, Object.keys(MDM_TOOLS));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Proves the five-check assertion against a tool that is already fully wired.
|
|
85
|
+
test('assertToolWired: augment is wired across all four tables', () => {
|
|
86
|
+
const got = assertToolWired('augment', {
|
|
87
|
+
script: 'augment/hooks/setup.py',
|
|
88
|
+
mdmScript: 'augment/hooks/mdm/setup.py',
|
|
89
|
+
bundles: ['SETUP_ALL_TOOLS', 'ALL_TOOLS', 'MDM_ALL_TOOLS', 'MDM_SETUP_ALL_TOOLS'],
|
|
90
|
+
});
|
|
91
|
+
assert.equal(SETUP_TOOL_MAP.augment.label, 'Augment Code');
|
|
92
|
+
assert.equal(got.bundles.length, 4);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// The regression half: a tool in SETUP_TOOL_MAP is in `--all --clear` for free,
|
|
96
|
+
// because resolveSetupAllTools(true) returns Object.keys(SETUP_TOOL_MAP).
|
|
97
|
+
test('resolveSetupAllTools: clear covers every SETUP_TOOL_MAP key, install covers the bundle only', () => {
|
|
98
|
+
assert.deepEqual(resolveSetupAllTools(true).sort(), Object.keys(SETUP_TOOL_MAP).sort());
|
|
99
|
+
const install = resolveSetupAllTools(false);
|
|
100
|
+
assert.ok(install.length < resolveSetupAllTools(true).length, install);
|
|
101
|
+
for (const name of install) {
|
|
102
|
+
assert.ok(SETUP_TOOL_MAP[name], `${name} is in the install bundle but not in SETUP_TOOL_MAP`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Without this the five checks could all pass vacuously on a typo'd tool name.
|
|
107
|
+
test('assertToolWired: a made-up tool fails every one of the five checks', () => {
|
|
108
|
+
const got = checkToolWired('definitely-not-a-tool', {
|
|
109
|
+
script: 'definitely-not-a-tool/setup.py',
|
|
110
|
+
mdmScript: 'definitely-not-a-tool/mdm/setup.py',
|
|
111
|
+
});
|
|
112
|
+
assert.equal(got.inSetupMap, false);
|
|
113
|
+
assert.equal(got.inMdmTools, false);
|
|
114
|
+
assert.equal(got.inPicker, false);
|
|
115
|
+
assert.equal(got.inHelp, false);
|
|
116
|
+
assert.equal(got.inSetupAllClear, false);
|
|
117
|
+
assert.deepEqual(got.bundles, []);
|
|
118
|
+
assert.throws(() => assertToolWired('definitely-not-a-tool', {
|
|
119
|
+
script: 'definitely-not-a-tool/setup.py',
|
|
120
|
+
mdmScript: 'definitely-not-a-tool/mdm/setup.py',
|
|
121
|
+
bundles: [],
|
|
122
|
+
}), /SETUP_TOOL_MAP entry missing/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// The source-read helpers are the fragile half of this file: if a rename made either
|
|
126
|
+
// block unfindable, every membership check would silently report false.
|
|
127
|
+
test('the source-read blocks are real and non-empty', () => {
|
|
128
|
+
assert.ok(PICKER_BLOCK.includes("value: 'cursor'"), 'SETUP_TOOLS block did not parse');
|
|
129
|
+
assert.ok(MDM_SETUP_ALL_TOOLS_BLOCK.includes("'cursor'"), 'MDM_SETUP_ALL_TOOLS did not parse');
|
|
130
|
+
assert.ok(HELP_TOOL_NAMES.length >= 10, HELP_TOOL_NAMES);
|
|
131
|
+
assert.ok(HELP_TOOL_NAMES.includes('cursor'), HELP_TOOL_NAMES);
|
|
132
|
+
});
|
|
@@ -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
|
+
});
|