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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.18",
3
+ "version": "1.16.20",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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(' Setup and discovery completed successfully — only scheduled-run setup failed.');
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(' Tool setup completed successfully — only discovery failed.');
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
  }));
@@ -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 = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
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 = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
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
+ });