@openagents-org/agent-launcher 0.2.169 → 0.2.170

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.169",
3
+ "version": "0.2.170",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/registry.json CHANGED
@@ -450,7 +450,8 @@
450
450
  ]
451
451
  },
452
452
  "check_ready": {
453
- "creds_file": "~/.gemini/oauth_creds.json",
453
+ "creds_file": "~/.gemini/google_accounts.json",
454
+ "creds_key": "active",
454
455
  "creds_no_parse": true,
455
456
  "creds_path_env": [
456
457
  "GOOGLE_APPLICATION_CREDENTIALS"
@@ -821,7 +822,7 @@
821
822
  "builtin": true,
822
823
  "install": {
823
824
  "binary": "hermes",
824
- "requires": ["python3"],
825
+ "requires": ["python3", "git"],
825
826
  "macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
826
827
  "linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup",
827
828
  "windows": "\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-RestMethod -UseBasicParsing 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' | Invoke-Expression\""
@@ -831,6 +832,7 @@
831
832
  },
832
833
  "check_ready": {
833
834
  "creds_file": "~/.hermes/config.yaml",
835
+ "creds_no_parse": true,
834
836
  "status_command": "hermes status",
835
837
  "login_command": "hermes setup",
836
838
  "not_ready_message": "Hermes not configured — run: hermes setup"
package/src/daemon.js CHANGED
@@ -7,6 +7,18 @@ const os = require('os');
7
7
  const { WorkspaceClient } = require('./workspace-client');
8
8
  const { getEnhancedEnv, whichBinary, IS_WINDOWS, defaultAgentWorkdir } = require('./paths');
9
9
 
10
+ /**
11
+ * Mask an API key for display (same shape the workspace backend uses for
12
+ * cloud agents): enough to recognize which key it is, never enough to use it.
13
+ * The partial reveal needs a real remainder to hide: first4+last4 of a
14
+ * 9-to-12-char key would expose most of it, so anything that short is
15
+ * fully masked.
16
+ */
17
+ function maskApiKey(key) {
18
+ if (!key || key.length <= 12) return '****';
19
+ return key.slice(0, 4) + '...' + key.slice(-4);
20
+ }
21
+
10
22
  /**
11
23
  * Agent process lifecycle manager.
12
24
  *
@@ -131,18 +143,21 @@ class Daemon {
131
143
  for (const a of this.config.getAgents()) {
132
144
  if (!this._agentOnNodeWorkspace(a, node)) continue;
133
145
  const proc = this._processes[a.name];
134
- // Model: per-agent env override, else the type-level saved model. Working
135
- // dir: the agent's configured path. Both power the workspace agent cards.
136
- let model = (a.env && a.env.LLM_MODEL) || null;
137
- if (!model) {
138
- try { model = (this.envManager.load(a.type) || {}).LLM_MODEL || null; } catch { model = null; }
139
- }
146
+ // Model/key: per-agent env override, else the type-level saved env.
147
+ // Both power the workspace agent cards; the key goes out MASKED only,
148
+ // so the edit form can show a key is configured without the secret
149
+ // ever leaving the device.
150
+ let typeEnv = {};
151
+ try { typeEnv = this.envManager.load(a.type) || {}; } catch {}
152
+ const model = (a.env && a.env.LLM_MODEL) || typeEnv.LLM_MODEL || null;
153
+ const apiKey = (a.env && a.env.LLM_API_KEY) || typeEnv.LLM_API_KEY || null;
140
154
  roster.push({
141
155
  name: a.name,
142
156
  type: a.type || 'unknown',
143
157
  status: (proc && proc.state) || 'stopped',
144
158
  model: model || null,
145
159
  workingDir: a.path || null,
160
+ apiKeyMasked: apiKey ? maskApiKey(apiKey) : null,
146
161
  });
147
162
  }
148
163
  } catch {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Pre-flight checks for install commands that shell out to a third-party script.
3
+ *
4
+ * `curl … | bash` installers are black boxes: whatever they decide to do about a
5
+ * missing dependency happens with no way for us to explain it, cancel it, or
6
+ * even show a useful progress line. Hermes is the case that forced this file to
7
+ * exist. Its install.sh needs git to clone the repo, and on a mac with no
8
+ * developer tools it:
9
+ * 1. runs `git --version`, which on macOS is a SHIM that opens the system
10
+ * "The 'git' command requires the command line developer tools" dialog —
11
+ * a dialog with no OpenAgents branding, appearing seconds after the user
12
+ * pressed Install in our marketplace;
13
+ * 2. calls `xcode-select --install`, opening it a second time;
14
+ * 3. sleeps in a 5-second poll loop for up to 900 SECONDS waiting for git to
15
+ * appear, printing one line per minute.
16
+ * In the launcher that reads as a progress bar frozen on "downloading" for a
17
+ * quarter of an hour, so users retry, which starts the whole thing again.
18
+ *
19
+ * Catching the missing dependency BEFORE spawning the installer turns all of
20
+ * that into one actionable message.
21
+ *
22
+ * Only dependencies with a probe below are enforced. `install.requires` is
23
+ * hand-maintained and partly aspirational — hermes lists python3 even though
24
+ * its installer provisions Python through uv when none is found — so an unknown
25
+ * entry is left to the installer rather than blocking an install that would
26
+ * have worked.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { execFileSync } = require('child_process');
34
+
35
+ const IS_MACOS = process.platform === 'darwin';
36
+
37
+ /** Directories on PATH, minus any the caller wants skipped. */
38
+ function pathDirs(exclude = []) {
39
+ const raw = process.env.PATH || '';
40
+ return raw
41
+ .split(path.delimiter)
42
+ .map((d) => d.trim())
43
+ .filter((d) => d && !exclude.includes(d));
44
+ }
45
+
46
+ function existsIn(dirs, name, exists, isWindows) {
47
+ const names = isWindows ? [`${name}.exe`, `${name}.cmd`, name] : [name];
48
+ for (const dir of dirs) {
49
+ for (const n of names) {
50
+ const full = path.join(dir, n);
51
+ try {
52
+ if (exists(full)) return full;
53
+ } catch {
54
+ /* unreadable dir on PATH — skip */
55
+ }
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * The active developer directory, or null when none is selected. `xcode-select
63
+ * -p` is safe to run with no tools installed: it exits 2 and prints to stderr
64
+ * WITHOUT opening the install dialog. Running `git` is what opens it.
65
+ */
66
+ function activeDeveloperDir() {
67
+ try {
68
+ return (
69
+ execFileSync('xcode-select', ['-p'], {
70
+ encoding: 'utf-8',
71
+ timeout: 5000,
72
+ stdio: ['ignore', 'pipe', 'ignore'],
73
+ }).trim() || null
74
+ );
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Is a REAL git available?
82
+ *
83
+ * The hard constraint on macOS: never execute `git`, and never execute
84
+ * `/usr/bin/git` in particular. That file exists on every mac whether or not
85
+ * the Command Line Tools are installed, and running it is precisely what opens
86
+ * the install dialog we are trying to get ahead of. So this probe only ever
87
+ * touches the filesystem plus `xcode-select -p`, which reports the active
88
+ * developer directory (exit code 2, no dialog, when there is none).
89
+ *
90
+ * Two platforms are deliberately exempt, because on them a missing git is
91
+ * something the installer genuinely recovers from:
92
+ *
93
+ * - **Windows.** install.ps1's Install-Git downloads PortableGit from
94
+ * github.com into %LOCALAPPDATA%\hermes\git — no admin rights, no dialog, no
95
+ * winget. It is slow (the script's own comment budgets five minutes) and it
96
+ * can fail on a restricted network, but blocking the install would break the
97
+ * many machines where it works. What Windows needs from us is a readable
98
+ * error when that download fails, not a refusal — see userFacingInstallError
99
+ * in the launcher.
100
+ * - **Linux as root.** install.sh shells out to apt/dnf/pacman, which succeeds
101
+ * without a password prompt when we are already root. As a normal user the
102
+ * sudo call has no tty to ask on and fails, so the refusal stands there.
103
+ *
104
+ * @param {object} [deps] - seams for tests; production passes nothing.
105
+ */
106
+ function probeGit(deps = {}) {
107
+ const platform = deps.platform || process.platform;
108
+ const exists = deps.exists || ((p) => fs.existsSync(p));
109
+ const dirs = deps.dirs || null;
110
+
111
+ if (platform === 'win32') {
112
+ return { ok: true, detail: 'installer provisions PortableGit itself' };
113
+ }
114
+
115
+ if (platform !== 'darwin') {
116
+ const search = dirs || [...pathDirs(), '/usr/bin', '/usr/local/bin'];
117
+ const found = existsIn(search, 'git', exists, false);
118
+ if (found) return { ok: true, detail: found };
119
+ const uid = deps.uid !== undefined ? deps.uid : (process.getuid ? process.getuid() : null);
120
+ if (uid === 0) return { ok: true, detail: 'root can install git unattended' };
121
+ return { ok: false };
122
+ }
123
+
124
+ // A git outside /usr/bin (Homebrew, MacPorts, a manual install) is a real
125
+ // binary and safe to trust without running it.
126
+ const search = dirs || [
127
+ ...pathDirs(['/usr/bin']),
128
+ '/opt/homebrew/bin',
129
+ '/usr/local/bin',
130
+ ];
131
+ const found = existsIn(search, 'git', exists, false);
132
+ if (found) return { ok: true, detail: found };
133
+
134
+ // Otherwise the only candidate left is the /usr/bin shim, which forwards to
135
+ // the active developer directory. Resolve that directory and look for the git
136
+ // it would forward to: present means the shim works, absent means running it
137
+ // would open the dialog — which is exactly the case we refuse to walk into.
138
+ const developerDir =
139
+ 'developerDir' in deps ? deps.developerDir : activeDeveloperDir();
140
+
141
+ const candidates = [];
142
+ if (developerDir) candidates.push(path.join(developerDir, 'usr', 'bin', 'git'));
143
+ candidates.push('/Library/Developer/CommandLineTools/usr/bin/git');
144
+ for (const c of candidates) {
145
+ try {
146
+ if (exists(c)) return { ok: true, detail: c };
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+ }
151
+
152
+ return { ok: false };
153
+ }
154
+
155
+ /**
156
+ * How the user fixes each missing dependency, phrased for the install screen.
157
+ * `action` is a hint the launcher UI can turn into a button; everything else
158
+ * degrades to plain text.
159
+ */
160
+ function gitRemedy() {
161
+ if (IS_MACOS) {
162
+ return {
163
+ name: 'git',
164
+ action: 'install-xcode-clt',
165
+ summary: 'Git is required, and it comes with the Xcode Command Line Tools.',
166
+ command: 'xcode-select --install',
167
+ alternative: 'brew install git',
168
+ };
169
+ }
170
+ return {
171
+ name: 'git',
172
+ action: null,
173
+ summary: 'Git is required to download this agent.',
174
+ command: 'sudo apt install git # or dnf / pacman, per your distro',
175
+ alternative: null,
176
+ };
177
+ }
178
+
179
+ const PROBES = {
180
+ git: { probe: probeGit, remedy: gitRemedy },
181
+ };
182
+
183
+ /**
184
+ * Check a registry entry's `install.requires` against the machine.
185
+ * @returns {{ ok: boolean, missing: Array<{name: string, action: string|null, summary: string, command: string, alternative: string|null}> }}
186
+ */
187
+ function checkInstallPrereqs(entry) {
188
+ const requires = entry && entry.install ? entry.install.requires : null;
189
+ if (!Array.isArray(requires) || requires.length === 0) return { ok: true, missing: [] };
190
+
191
+ const missing = [];
192
+ for (const name of requires) {
193
+ const spec = PROBES[String(name || '').toLowerCase()];
194
+ if (!spec) continue; // no probe → the installer's problem, not ours
195
+ let result;
196
+ try {
197
+ result = spec.probe();
198
+ } catch {
199
+ continue; // a probe that throws must never block an install
200
+ }
201
+ if (!result || !result.ok) missing.push(spec.remedy());
202
+ }
203
+ return { ok: missing.length === 0, missing };
204
+ }
205
+
206
+ /**
207
+ * The error thrown in place of running the installer. Carries `code` and
208
+ * `missing` so the launcher can render buttons instead of parsing prose, and a
209
+ * message that stands on its own for the CLI and the log.
210
+ */
211
+ function missingPrereqError(agentLabel, missing) {
212
+ const lines = [
213
+ `${agentLabel} cannot be installed yet — a required tool is missing.`,
214
+ '',
215
+ ];
216
+ for (const item of missing) {
217
+ lines.push(`• ${item.summary}`);
218
+ lines.push(` Install it with: ${item.command}`);
219
+ if (item.alternative) lines.push(` Or: ${item.alternative}`);
220
+ }
221
+ lines.push('', 'Once it is installed, come back and press Install again.');
222
+
223
+ const err = new Error(lines.join('\n'));
224
+ err.code = 'MISSING_PREREQ';
225
+ err.missing = missing;
226
+ return err;
227
+ }
228
+
229
+ module.exports = {
230
+ checkInstallPrereqs,
231
+ missingPrereqError,
232
+ probeGit,
233
+ };
package/src/installer.js CHANGED
@@ -8,10 +8,32 @@ const { whichBinary, getEnhancedEnv, getRuntimePrefix, clearBinaryLookupCache, a
8
8
  const { EnvManager } = require('./env');
9
9
  const { nodeDistUrls, installRegistry } = require('./mirrors');
10
10
  const { readinessReason, REASON } = require('./adapters/health-status');
11
+ const { checkInstallPrereqs, missingPrereqError } = require('./install-preflight');
12
+ const { detectShadowedNodeShims, shadowedNodeWarning } = require('./node-shims');
11
13
 
12
14
  const STATUS_CACHE_TTL_MS = 10000;
13
15
  const statusCache = new Map();
14
16
 
17
+ /**
18
+ * How long a streaming install may produce NO output before it is treated as
19
+ * hung and killed. Generous on purpose: a slow npm postinstall or a large
20
+ * extraction can legitimately go quiet for a while, and killing a working
21
+ * install is worse than waiting. Set OPENAGENTS_INSTALL_STALL_MS=0 to disable.
22
+ */
23
+ const INSTALL_STALL_MS = (() => {
24
+ const raw = process.env.OPENAGENTS_INSTALL_STALL_MS;
25
+ if (raw === undefined || raw === '') return 10 * 60 * 1000;
26
+ const n = Number(raw);
27
+ return Number.isFinite(n) && n >= 0 ? n : 10 * 60 * 1000;
28
+ })();
29
+
30
+ /**
31
+ * How often the stall watchdog checks. Capped at half the stall budget so the
32
+ * poll granularity never dominates the deadline (matters for a short budget in
33
+ * tests; in production the 15s ceiling applies).
34
+ */
35
+ const STALL_POLL_MS = Math.max(50, Math.min(15000, Math.floor(INSTALL_STALL_MS / 2) || 15000));
36
+
15
37
  // Version detection is comparatively expensive (spawns `<bin> --version`), so
16
38
  // results are cached briefly and invalidated on install/uninstall.
17
39
  const VERSION_CACHE_TTL_MS = 60000;
@@ -868,6 +890,17 @@ class Installer {
868
890
  }
869
891
  return 'absent';
870
892
  }
893
+ // A named field, for a file that exists in both states. Gemini's
894
+ // google_accounts.json is written on install and records a signed-OUT
895
+ // account as `active: null`, so its existence proves nothing — only the
896
+ // field does. Still content-free: the value is tested for emptiness, never
897
+ // read out, logged or returned.
898
+ if (checkReady.creds_key) {
899
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
900
+ const value = parsed && typeof parsed === 'object' ? parsed[checkReady.creds_key] : null;
901
+ const filled = typeof value === 'string' ? !!value.trim() : !!value;
902
+ return filled ? 'present' : 'absent';
903
+ }
871
904
  return st.size > 0 ? 'present' : 'absent';
872
905
  } catch {
873
906
  return 'unreadable';
@@ -965,6 +998,8 @@ class Installer {
965
998
  return { success: true, output: `${entry.label || agentType} uses direct API mode; no binary install needed.` };
966
999
  }
967
1000
 
1001
+ this._assertPrereqs(agentType, entry);
1002
+
968
1003
  let cmd = this._getInstallCommand(entry.install);
969
1004
  if (!cmd) {
970
1005
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -1034,6 +1069,8 @@ class Installer {
1034
1069
  return { success: true, command: 'api-only' };
1035
1070
  }
1036
1071
 
1072
+ this._assertPrereqs(agentType, entry, onData);
1073
+
1037
1074
  let rawCmd = this._getInstallCommand(entry.install);
1038
1075
  if (!rawCmd) {
1039
1076
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -1116,14 +1153,30 @@ class Installer {
1116
1153
 
1117
1154
  return new Promise((resolve, reject) => {
1118
1155
  // windowsHide stops a console window from flashing up on every install.
1119
- const proc = spawn(spawnFile, spawnArgs, { shell: useShell, env, cwd: installCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
1156
+ //
1157
+ // detached (Unix): puts the installer in its own process group, which is
1158
+ // what makes the stall watchdog able to stop the WHOLE tree. Without it
1159
+ // the direct child is a shell and the actual work — curl, git, uv — runs
1160
+ // in grandchildren that survive a signal aimed at the shell. It does not
1161
+ // change lifetime semantics: an install was never killed by the launcher
1162
+ // exiting, before or after this.
1163
+ const proc = spawn(spawnFile, spawnArgs, {
1164
+ shell: useShell,
1165
+ env,
1166
+ cwd: installCwd,
1167
+ stdio: ['ignore', 'pipe', 'pipe'],
1168
+ windowsHide: true,
1169
+ detached: process.platform !== 'win32',
1170
+ });
1120
1171
 
1121
1172
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
1122
1173
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
1123
1174
 
1124
1175
  let outputTail = '';
1176
+ let lastOutputAt = Date.now();
1125
1177
  const captureOutput = (d) => {
1126
1178
  const text = String(d);
1179
+ lastOutputAt = Date.now();
1127
1180
  outputTail = (outputTail + text).slice(-4000);
1128
1181
  if (onData) onData(text);
1129
1182
  };
@@ -1131,8 +1184,40 @@ class Installer {
1131
1184
  if (proc.stdout) proc.stdout.on('data', captureOutput);
1132
1185
  if (proc.stderr) proc.stderr.on('data', captureOutput);
1133
1186
 
1134
- proc.on('error', (err) => reject(err));
1187
+ // Stall watchdog. A third-party installer that decides to wait — hermes's
1188
+ // install.sh polls for up to 900s after asking macOS for the developer
1189
+ // tools — otherwise hangs this promise indefinitely, and the only visible
1190
+ // symptom is a progress bar that stopped moving. The preflight now
1191
+ // prevents that specific case, but the general shape (a download that
1192
+ // never returns, a prompt on a stdin nobody is reading) is not specific
1193
+ // to any one installer, so time is bounded here for all of them.
1194
+ let stallTimer = null;
1195
+ let stalled = false;
1196
+ const clearStallTimer = () => {
1197
+ if (stallTimer) { clearInterval(stallTimer); stallTimer = null; }
1198
+ };
1199
+ if (INSTALL_STALL_MS > 0) {
1200
+ stallTimer = setInterval(() => {
1201
+ if (Date.now() - lastOutputAt < INSTALL_STALL_MS) return;
1202
+ stalled = true;
1203
+ clearStallTimer();
1204
+ const minutes = Math.round(INSTALL_STALL_MS / 60000);
1205
+ const msg = `Install stalled: no output for ${minutes} minutes. `
1206
+ + 'The installer was stopped. It may be waiting on a prompt, or on a '
1207
+ + 'download that never returns.';
1208
+ if (onData) onData(`\n${msg}\n`);
1209
+ this._killProcessTree(proc);
1210
+ reject(new Error(msg));
1211
+ }, STALL_POLL_MS);
1212
+ // Never hold the event loop open for the watchdog alone.
1213
+ if (typeof stallTimer.unref === 'function') stallTimer.unref();
1214
+ }
1215
+
1216
+ proc.on('error', (err) => { clearStallTimer(); reject(err); });
1135
1217
  proc.on('close', (code) => {
1218
+ clearStallTimer();
1219
+ // The watchdog already rejected; this close is the kill it performed.
1220
+ if (stalled) return;
1136
1221
  if (code === 0) {
1137
1222
  // Aider-only: confirm a real, genuine binary exists before recording
1138
1223
  // the install (verify-before-mark; never writes a marker on
@@ -1170,6 +1255,13 @@ class Installer {
1170
1255
  return;
1171
1256
  }
1172
1257
  if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
1258
+ // Its installer may have repointed the machine's node/npm/npx at
1259
+ // its own Node. See node-shims.js — we report, never rewrite.
1260
+ const shimWarning = shadowedNodeWarning(
1261
+ entry.label || agentType,
1262
+ detectShadowedNodeShims(),
1263
+ );
1264
+ if (shimWarning && onData) onData(`${shimWarning}\n`);
1173
1265
  }
1174
1266
  // Cursor-only: same failure shape as hermes above — its installer
1175
1267
  // exits 0 after a failed download. See _verifyCursorBinary.
@@ -1350,6 +1442,51 @@ class Installer {
1350
1442
  });
1351
1443
  }
1352
1444
 
1445
+ /**
1446
+ * Refuse to run a third-party installer whose hard dependencies are missing.
1447
+ *
1448
+ * See install-preflight.js for why this is worth doing before the spawn: the
1449
+ * scripts we shell out to handle a missing dependency by opening OS dialogs
1450
+ * and sleeping for minutes, which the launcher can only render as a frozen
1451
+ * progress bar.
1452
+ */
1453
+ _assertPrereqs(agentType, entry, onData, check = checkInstallPrereqs) {
1454
+ const { ok, missing } = check(entry);
1455
+ if (ok) return;
1456
+ const err = missingPrereqError(entry.label || agentType, missing);
1457
+ if (onData) onData(`${err.message}\n`);
1458
+ throw err;
1459
+ }
1460
+
1461
+ /**
1462
+ * Stop a spawned installer and everything it started.
1463
+ *
1464
+ * The child is usually a shell (`curl … | bash`, powershell) whose real work
1465
+ * happens in grandchildren, so signalling the direct child alone leaves the
1466
+ * download running. On Unix the process group is signalled; on Windows
1467
+ * taskkill /T walks the tree.
1468
+ */
1469
+ _killProcessTree(proc) {
1470
+ if (!proc || proc.exitCode !== null) return;
1471
+ try {
1472
+ if (process.platform === 'win32') {
1473
+ try {
1474
+ execSync(`taskkill /F /T /PID ${proc.pid}`, {
1475
+ stdio: 'ignore', timeout: 5000, windowsHide: true,
1476
+ });
1477
+ } catch { proc.kill(); }
1478
+ return;
1479
+ }
1480
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
1481
+ setTimeout(() => {
1482
+ if (proc.exitCode !== null) return;
1483
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { proc.kill('SIGKILL'); }
1484
+ }, 5000).unref();
1485
+ } catch {
1486
+ /* the process is already gone */
1487
+ }
1488
+ }
1489
+
1353
1490
  /**
1354
1491
  * Get install command for current platform.
1355
1492
  */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Detecting an agent installer that has taken over the machine's `node`.
3
+ *
4
+ * Hermes's install.sh, when it decides the system Node is too old, downloads
5
+ * its own and then does this:
6
+ *
7
+ * ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
8
+ * ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
9
+ * ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
10
+ *
11
+ * `ln -sf` overwrites. On a machine where ~/.local/bin comes before nvm or
12
+ * Homebrew on PATH — a very common layout — the user's default `node` silently
13
+ * becomes the agent's, for every shell and every unrelated project, and npm's
14
+ * global prefix is rewritten to ~/.local along with it. Nothing announces this,
15
+ * and nothing connects a project that suddenly builds differently back to
16
+ * having installed an agent.
17
+ *
18
+ * This module only looks and reports. Rewriting a user's PATH or deleting their
19
+ * symlinks would be a second uninvited change on top of the first, and the
20
+ * agent's Node may be exactly what they want. Telling them precisely what
21
+ * happened, and how to undo it, is the part that was missing.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ /**
31
+ * node/npm/npx under `linkDir` that are symlinks into `ownerDir`, together with
32
+ * whether they shadow another installation the user already had.
33
+ *
34
+ * @param {object} [deps] - seams for tests; production passes nothing.
35
+ * @returns {{ shims: Array<{name: string, link: string, target: string}>, shadowed: string|null }}
36
+ */
37
+ function detectShadowedNodeShims(deps = {}) {
38
+ const home = deps.home || os.homedir();
39
+ const linkDir = deps.linkDir || path.join(home, '.local', 'bin');
40
+ const ownerDir = deps.ownerDir || path.join(home, '.hermes', 'node', 'bin');
41
+ const readLink = deps.readLink || defaultReadLink;
42
+ const exists = deps.exists || ((p) => fs.existsSync(p));
43
+ const entries = deps.pathEntries || (process.env.PATH || '').split(path.delimiter);
44
+
45
+ const shims = [];
46
+ for (const name of ['node', 'npm', 'npx']) {
47
+ const link = path.join(linkDir, name);
48
+ const target = readLink(link);
49
+ if (!target) continue;
50
+ const resolved = path.resolve(linkDir, target);
51
+ if (resolved === path.join(ownerDir, name) || resolved.startsWith(ownerDir + path.sep)) {
52
+ shims.push({ name, link, target: resolved });
53
+ }
54
+ }
55
+ if (shims.length === 0) return { shims: [], shadowed: null };
56
+
57
+ // Only a problem if the user HAD another node. On a machine with none, these
58
+ // symlinks are the installer doing something useful.
59
+ const normalized = entries.map((d) => d.trim()).filter(Boolean);
60
+ const linkIndex = normalized.indexOf(linkDir);
61
+ const after = linkIndex === -1 ? normalized : normalized.slice(linkIndex + 1);
62
+ for (const dir of after) {
63
+ if (dir === linkDir) continue;
64
+ const candidate = path.join(dir, 'node');
65
+ try {
66
+ if (exists(candidate)) return { shims, shadowed: candidate };
67
+ } catch {
68
+ /* unreadable dir on PATH — skip */
69
+ }
70
+ }
71
+ return { shims, shadowed: null };
72
+ }
73
+
74
+ /** lstat + readlink, or null when the path is absent or not a symlink. */
75
+ function defaultReadLink(p) {
76
+ try {
77
+ if (!fs.lstatSync(p).isSymbolicLink()) return null;
78
+ return fs.readlinkSync(p);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The warning text, or null when there is nothing worth saying. Written to be
86
+ * read in an install log by someone who did not expect any of this.
87
+ */
88
+ function shadowedNodeWarning(agentLabel, detection) {
89
+ if (!detection || detection.shims.length === 0 || !detection.shadowed) return null;
90
+ const names = detection.shims.map((s) => s.name).join(', ');
91
+ const dir = path.dirname(detection.shims[0].link);
92
+ return [
93
+ '',
94
+ `Note: the ${agentLabel} installer replaced ${names} in ${dir} with links to its own Node.`,
95
+ `That now takes precedence over ${detection.shadowed} for every shell on this machine,`,
96
+ 'so other projects will build with the agent\'s Node version from here on.',
97
+ '',
98
+ 'To put your own Node back, remove the links (this does not affect the agent):',
99
+ ` rm -f ${detection.shims.map((s) => s.link).join(' ')}`,
100
+ ].join('\n');
101
+ }
102
+
103
+ module.exports = { detectShadowedNodeShims, shadowedNodeWarning };