@openagents-org/agent-connector 0.2.6 → 0.2.7

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-connector",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Agent management CLI and library for OpenAgents — install, configure, and run AI coding agents",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -11,7 +11,13 @@
11
11
  "build:registry": "node scripts/build-registry.js",
12
12
  "lint": "eslint src/"
13
13
  },
14
- "keywords": ["openagents", "ai", "agent", "coding", "cli"],
14
+ "keywords": [
15
+ "openagents",
16
+ "ai",
17
+ "agent",
18
+ "coding",
19
+ "cli"
20
+ ],
15
21
  "author": "OpenAgents",
16
22
  "license": "MIT",
17
23
  "repository": {
package/src/config.js CHANGED
@@ -149,7 +149,7 @@ class Config {
149
149
  try {
150
150
  if (!fs.existsSync(this.logFile)) return [];
151
151
  const content = fs.readFileSync(this.logFile, 'utf-8');
152
- let allLines = content.split('\n');
152
+ let allLines = content.split('\n').filter(Boolean);
153
153
  if (agentName) {
154
154
  allLines = allLines.filter(
155
155
  (l) => l.includes(agentName) || l.includes('daemon') || l.includes('Daemon')
@@ -160,6 +160,37 @@ class Config {
160
160
  return [];
161
161
  }
162
162
  }
163
+
164
+ /**
165
+ * Tail log file with optional filter. Returns { lines, size }.
166
+ * @param {Object} opts - { agent, lines, offset }
167
+ * @param {string} [opts.agent] - Filter by agent name
168
+ * @param {number} [opts.lines=100] - Number of lines to return
169
+ * @param {number} [opts.offset=0] - Byte offset for incremental reads (0 = from end)
170
+ */
171
+ tailLogs(opts = {}) {
172
+ const { agent, lines = 100, offset = 0 } = opts;
173
+ try {
174
+ if (!fs.existsSync(this.logFile)) return { lines: [], size: 0 };
175
+ const stat = fs.statSync(this.logFile);
176
+ if (offset > 0 && offset < stat.size) {
177
+ // Incremental read from offset
178
+ const fd = fs.openSync(this.logFile, 'r');
179
+ const buf = Buffer.alloc(stat.size - offset);
180
+ fs.readSync(fd, buf, 0, buf.length, offset);
181
+ fs.closeSync(fd);
182
+ let newLines = buf.toString('utf-8').split('\n').filter(Boolean);
183
+ if (agent) {
184
+ newLines = newLines.filter(l => l.includes(agent) || l.includes('Daemon'));
185
+ }
186
+ return { lines: newLines, size: stat.size };
187
+ }
188
+ // Full read, return last N
189
+ return { lines: this.getLogs(agent, lines), size: stat.size };
190
+ } catch {
191
+ return { lines: [], size: 0 };
192
+ }
193
+ }
163
194
  }
164
195
 
165
196
  // -- YAML parser (compatible with Python SDK's daemon.yaml format) --
package/src/daemon.js CHANGED
@@ -5,8 +5,7 @@ const path = require('path');
5
5
  const { spawn, execSync, execFileSync } = require('child_process');
6
6
  const os = require('os');
7
7
  const { WorkspaceClient } = require('./workspace-client');
8
-
9
- const IS_WINDOWS = process.platform === 'win32';
8
+ const { getEnhancedEnv, whichBinary, IS_WINDOWS } = require('./paths');
10
9
 
11
10
  /**
12
11
  * Agent process lifecycle manager.
@@ -63,7 +62,11 @@ class Daemon {
63
62
  this._processCommands();
64
63
  }, 5000);
65
64
 
65
+ // Watch config file for hot-reload
66
+ this._watchConfig();
67
+
66
68
  this._writeStatus();
69
+ this._cachedAgentNames = new Set(agents.map(a => a.name));
67
70
  this._log(`Daemon started with ${agents.length} agent(s)`);
68
71
 
69
72
  // Block until shutdown
@@ -82,6 +85,7 @@ class Daemon {
82
85
 
83
86
  if (this._statusInterval) clearInterval(this._statusInterval);
84
87
  if (this._cmdInterval) clearInterval(this._cmdInterval);
88
+ if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
85
89
 
86
90
  // Kill all child processes
87
91
  const kills = Object.keys(this._processes).map((name) =>
@@ -418,22 +422,22 @@ class Daemon {
418
422
  const [binary, ...args] = cmd;
419
423
  const spawnOpts = {
420
424
  stdio: ['ignore', 'pipe', 'pipe'],
421
- env: opts.env,
425
+ env: getEnhancedEnv(opts.env),
422
426
  cwd: opts.cwd,
423
427
  };
424
428
 
425
429
  if (IS_WINDOWS) {
426
430
  // On Windows, always use shell so .cmd/.ps1 shims on PATH are found
431
+ // Use cmd /c with chcp 65001 to force UTF-8 output (fixes GBK garbled text)
427
432
  spawnOpts.shell = true;
428
- // Ensure npm global bin is on PATH
429
- const npmBin = path.join(process.env.APPDATA || '', 'npm');
430
- if (npmBin && !(process.env.PATH || '').includes(npmBin)) {
431
- spawnOpts.env = { ...spawnOpts.env, PATH: npmBin + ';' + (spawnOpts.env.PATH || process.env.PATH || '') };
432
- }
433
433
  }
434
434
 
435
435
  const proc = spawn(binary, args, spawnOpts);
436
436
 
437
+ // Force UTF-8 decoding on stdout/stderr
438
+ if (proc.stdout) proc.stdout.setEncoding('utf-8');
439
+ if (proc.stderr) proc.stderr.setEncoding('utf-8');
440
+
437
441
  // Merge stderr into stdout handler
438
442
  if (proc.stderr) {
439
443
  proc.stderr.on('data', (chunk) => {
@@ -555,28 +559,41 @@ class Daemon {
555
559
  } catch {}
556
560
  }
557
561
 
562
+ _watchConfig() {
563
+ try {
564
+ let debounce = null;
565
+ this._configWatcher = fs.watch(this.config.configFile, () => {
566
+ if (debounce) clearTimeout(debounce);
567
+ debounce = setTimeout(() => this._reload(), 1000);
568
+ });
569
+ this._configWatcher.on('error', () => {});
570
+ } catch {}
571
+ }
572
+
558
573
  _reload() {
559
574
  this._log('Reloading config...');
560
- const oldAgents = new Map(this.config.getAgents().map((a) => [a.name, a]));
561
- // Re-read config from disk (Config reads fresh on each call)
562
- const newAgents = new Map(this.config.getAgents().map((a) => [a.name, a]));
575
+ const oldNames = this._cachedAgentNames || new Set();
576
+ // Re-read config from disk
577
+ const newAgents = this.config.getAgents();
578
+ const newNames = new Set(newAgents.map(a => a.name));
563
579
 
564
580
  // Stop removed agents
565
- for (const name of oldAgents.keys()) {
566
- if (!newAgents.has(name)) {
581
+ for (const name of oldNames) {
582
+ if (!newNames.has(name)) {
567
583
  this.stopAgent(name);
568
584
  this._log(`Reload: stopped removed agent '${name}'`);
569
585
  }
570
586
  }
571
587
 
572
588
  // Start new agents
573
- for (const [name, agent] of newAgents) {
574
- if (!oldAgents.has(name)) {
589
+ for (const agent of newAgents) {
590
+ if (!oldNames.has(agent.name)) {
575
591
  this._launchAgent(agent);
576
- this._log(`Reload: started new agent '${name}'`);
592
+ this._log(`Reload: started new agent '${agent.name}'`);
577
593
  }
578
594
  }
579
595
 
596
+ this._cachedAgentNames = newNames;
580
597
  this._writeStatus();
581
598
  }
582
599
 
@@ -596,13 +613,26 @@ class Daemon {
596
613
  const line = `${ts} INFO daemon: ${msg}`;
597
614
  try {
598
615
  fs.appendFileSync(this.config.logFile, line + '\n', 'utf-8');
616
+ this._maybeRotateLog();
599
617
  } catch {}
600
618
  if (!this._shuttingDown) {
601
- // Also log to console when running in foreground
602
619
  console.log(line);
603
620
  }
604
621
  }
605
622
 
623
+ _maybeRotateLog() {
624
+ // Rotate at 10MB, keep 1 backup
625
+ const MAX_SIZE = 10 * 1024 * 1024;
626
+ try {
627
+ const stat = fs.statSync(this.config.logFile);
628
+ if (stat.size > MAX_SIZE) {
629
+ const backup = this.config.logFile + '.1';
630
+ try { fs.unlinkSync(backup); } catch {}
631
+ fs.renameSync(this.config.logFile, backup);
632
+ }
633
+ } catch {}
634
+ }
635
+
606
636
  _sleep(ms) {
607
637
  return new Promise((resolve) => setTimeout(resolve, ms));
608
638
  }
package/src/index.js CHANGED
@@ -56,6 +56,10 @@ class AgentConnector {
56
56
  return this.installer.isInstalled(agentType);
57
57
  }
58
58
 
59
+ healthCheck(agentType) {
60
+ return this.installer.healthCheck(agentType);
61
+ }
62
+
59
63
  // -- Agent CRUD --
60
64
 
61
65
  listAgents() {
@@ -197,4 +201,5 @@ class AgentConnector {
197
201
 
198
202
  const adapters = require('./adapters');
199
203
 
200
- module.exports = { AgentConnector, Daemon, WorkspaceClient, adapters };
204
+ const paths = require('./paths');
205
+ module.exports = { AgentConnector, Daemon, WorkspaceClient, adapters, paths };
package/src/installer.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execSync, exec } = require('child_process');
6
+ const { whichBinary, getEnhancedEnv } = require('./paths');
6
7
 
7
8
  /**
8
9
  * Manages installation and uninstallation of agent runtimes.
@@ -45,6 +46,34 @@ class Installer {
45
46
  return this._whichBinary(agentType);
46
47
  }
47
48
 
49
+ /**
50
+ * Health check — binary existence + version.
51
+ * @returns {{ installed: boolean, binary: string|null, version: string|null }}
52
+ */
53
+ healthCheck(agentType) {
54
+ const binary = this._whichBinary(agentType);
55
+ if (!binary) return { installed: false, binary: null, version: null };
56
+
57
+ const entry = this.registry.getEntry(agentType);
58
+ const checkCmd = entry && entry.install ? entry.install.check_command : null;
59
+ const versionCmd = checkCmd || `${entry && entry.install && entry.install.binary || agentType} --version`;
60
+
61
+ let version = null;
62
+ try {
63
+ const raw = execSync(versionCmd, {
64
+ encoding: 'utf-8',
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ env: getEnhancedEnv(),
67
+ timeout: 10000,
68
+ }).trim();
69
+ // Extract version number (e.g. "openclaw 2024.1.5" → "2024.1.5")
70
+ const match = raw.match(/(\d+[\d.]+\d+)/);
71
+ version = match ? match[1] : raw.split('\n')[0];
72
+ } catch {}
73
+
74
+ return { installed: true, binary, version };
75
+ }
76
+
48
77
  /**
49
78
  * Install an agent runtime.
50
79
  * @returns {Promise<{success: boolean, output: string}>}
@@ -125,32 +154,12 @@ class Installer {
125
154
  }
126
155
 
127
156
  /**
128
- * Find a binary on PATH.
157
+ * Find a binary on PATH (delegates to paths.js for cross-platform detection).
129
158
  */
130
159
  _whichBinary(agentType) {
131
160
  const entry = this.registry.getEntry(agentType);
132
161
  const binary = entry && entry.install ? entry.install.binary : agentType;
133
- if (!binary) return null;
134
-
135
- try {
136
- const cmd = process.platform === 'win32' ? `where ${binary}` : `which ${binary}`;
137
- const env = { ...process.env };
138
- const extraPaths = ['/usr/local/bin', '/opt/homebrew/bin'];
139
- if (process.platform === 'win32') {
140
- const npmBin = path.join(process.env.APPDATA || '', 'npm');
141
- if (npmBin) extraPaths.push(npmBin);
142
- extraPaths.push('C:\\Program Files\\nodejs');
143
- }
144
- for (const p of extraPaths) {
145
- if (p && !(env.PATH || '').includes(p)) {
146
- env.PATH = p + (process.platform === 'win32' ? ';' : ':') + (env.PATH || '');
147
- }
148
- }
149
- const result = execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env }).trim();
150
- return result.split('\n')[0] || null;
151
- } catch {
152
- return null;
153
- }
162
+ return whichBinary(binary);
154
163
  }
155
164
 
156
165
  // -- Markers --
@@ -218,35 +227,18 @@ class Installer {
218
227
 
219
228
  _execShell(cmd, timeoutMs = 300000) {
220
229
  return new Promise((resolve, reject) => {
221
- // Ensure common binary paths are available (Electron may not inherit full PATH)
222
- const env = { ...process.env };
223
- const extraPaths = [
224
- '/usr/local/bin', '/opt/homebrew/bin',
225
- path.dirname(process.execPath),
226
- ];
227
- if (process.platform === 'win32') {
228
- const npmBin = path.join(process.env.APPDATA || '', 'npm');
229
- if (npmBin) extraPaths.push(npmBin);
230
- // Common binary locations on Windows
231
- extraPaths.push('C:\\Program Files\\nodejs');
232
- extraPaths.push('C:\\Program Files (x86)\\nodejs');
233
- extraPaths.push('C:\\Program Files\\Git\\cmd');
234
- extraPaths.push('C:\\Program Files (x86)\\Git\\cmd');
235
- // Also try to find node/git via where
236
- for (const bin of ['node', 'git']) {
237
- try {
238
- const p = execSync(`where ${bin}`, { encoding: 'utf-8', timeout: 3000 }).split(/\r?\n/)[0].trim();
239
- if (p) extraPaths.push(path.dirname(p));
240
- } catch {}
241
- }
242
- }
243
- for (const p of extraPaths) {
244
- if (p && !(env.PATH || '').includes(p)) {
245
- env.PATH = p + (process.platform === 'win32' ? ';' : ':') + (env.PATH || '');
246
- }
230
+ const env = getEnhancedEnv();
231
+ // Also include Electron's own binary dir
232
+ const execDir = path.dirname(process.execPath);
233
+ if (execDir && !(env.PATH || '').includes(execDir)) {
234
+ const sep = process.platform === 'win32' ? ';' : ':';
235
+ env.PATH = execDir + sep + (env.PATH || '');
247
236
  }
248
237
 
249
- exec(cmd, {
238
+ // On Windows, force UTF-8 codepage to avoid GBK garbled output
239
+ const shellCmd = process.platform === 'win32' ? `chcp 65001 >nul && ${cmd}` : cmd;
240
+
241
+ exec(shellCmd, {
250
242
  encoding: 'utf-8',
251
243
  timeout: timeoutMs,
252
244
  shell: true,
package/src/paths.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Cross-platform PATH detection.
3
+ *
4
+ * Finds binary directories for Node.js version managers (nvm, fnm, volta),
5
+ * package managers (npm, Homebrew, pip), and standard system locations.
6
+ * Used by installer.js (binary detection) and daemon.js (agent spawning).
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const { execSync } = require('child_process');
14
+
15
+ const IS_WINDOWS = process.platform === 'win32';
16
+ const IS_MACOS = process.platform === 'darwin';
17
+ const SEP = IS_WINDOWS ? ';' : ':';
18
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
19
+
20
+ /**
21
+ * Get all extra binary directories that should be checked beyond process.env.PATH.
22
+ * Returns deduplicated list of existing directories.
23
+ */
24
+ function getExtraBinDirs() {
25
+ const dirs = [];
26
+
27
+ if (IS_WINDOWS) {
28
+ _addWindowsPaths(dirs);
29
+ } else {
30
+ _addUnixPaths(dirs);
31
+ if (IS_MACOS) {
32
+ _addMacPaths(dirs);
33
+ }
34
+ }
35
+
36
+ // Common: ~/.local/bin (pipx, user installs)
37
+ _push(dirs, path.join(HOME, '.local', 'bin'));
38
+
39
+ // Filter to existing directories only, deduplicate
40
+ const seen = new Set();
41
+ const currentPATH = process.env.PATH || '';
42
+ return dirs.filter(d => {
43
+ if (!d || seen.has(d) || currentPATH.includes(d)) return false;
44
+ seen.add(d);
45
+ try {
46
+ return fs.statSync(d).isDirectory();
47
+ } catch {
48
+ return false;
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Build a full PATH string that includes all extra bin dirs prepended.
55
+ */
56
+ function getEnhancedPATH() {
57
+ const extra = getExtraBinDirs();
58
+ const current = process.env.PATH || '';
59
+ if (extra.length === 0) return current;
60
+ return extra.join(SEP) + SEP + current;
61
+ }
62
+
63
+ /**
64
+ * Build an env object with enhanced PATH for spawning subprocesses.
65
+ */
66
+ function getEnhancedEnv(baseEnv) {
67
+ const env = { ...(baseEnv || process.env) };
68
+ const extra = getExtraBinDirs();
69
+ if (extra.length > 0) {
70
+ env.PATH = extra.join(SEP) + SEP + (env.PATH || '');
71
+ }
72
+ if (IS_WINDOWS) {
73
+ // Force UTF-8 output from child processes on non-English Windows locales
74
+ // (prevents GBK/Shift-JIS garbled text in error messages)
75
+ env.PYTHONIOENCODING = env.PYTHONIOENCODING || 'utf-8';
76
+ env.PYTHONUTF8 = env.PYTHONUTF8 || '1';
77
+ env.LANG = env.LANG || 'en_US.UTF-8';
78
+ }
79
+ return env;
80
+ }
81
+
82
+ /**
83
+ * Find a binary by name. Returns full path or null.
84
+ */
85
+ function whichBinary(name) {
86
+ if (!name) return null;
87
+ const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
88
+ try {
89
+ const result = execSync(cmd, {
90
+ encoding: 'utf-8',
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ env: { ...process.env, PATH: getEnhancedPATH() },
93
+ timeout: 5000,
94
+ }).trim();
95
+ return result.split(/\r?\n/)[0] || null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // ---- Windows paths ----
102
+
103
+ function _addWindowsPaths(dirs) {
104
+ const appData = process.env.APPDATA || '';
105
+ const localAppData = process.env.LOCALAPPDATA || '';
106
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
107
+
108
+ // npm global bin
109
+ if (appData) _push(dirs, path.join(appData, 'npm'));
110
+
111
+ // Node.js install
112
+ _push(dirs, path.join(programFiles, 'nodejs'));
113
+
114
+ // nvm for Windows
115
+ const nvmHome = process.env.NVM_HOME;
116
+ if (nvmHome) {
117
+ _push(dirs, nvmHome);
118
+ // nvm symlink dir
119
+ const nvmSymlink = process.env.NVM_SYMLINK || path.join(programFiles, 'nodejs');
120
+ _push(dirs, nvmSymlink);
121
+ }
122
+
123
+ // fnm
124
+ if (localAppData) _push(dirs, path.join(localAppData, 'fnm_multishells'));
125
+ const fnmDir = process.env.FNM_DIR || path.join(appData, 'fnm');
126
+ if (fnmDir) {
127
+ // fnm aliases — current version
128
+ try {
129
+ const defaultDir = path.join(fnmDir, 'aliases', 'default');
130
+ if (fs.existsSync(defaultDir)) _push(dirs, defaultDir);
131
+ } catch {}
132
+ }
133
+
134
+ // volta
135
+ const voltaHome = process.env.VOLTA_HOME || path.join(localAppData, 'Volta');
136
+ _push(dirs, path.join(voltaHome, 'bin'));
137
+
138
+ // Git (needed for some installers)
139
+ _push(dirs, path.join(programFiles, 'Git', 'cmd'));
140
+ _push(dirs, path.join(programFiles, 'Git', 'bin'));
141
+
142
+ // Python/pip
143
+ if (localAppData) {
144
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'));
145
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'));
146
+ _push(dirs, path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'));
147
+ }
148
+ }
149
+
150
+ // ---- Unix paths ----
151
+
152
+ function _addUnixPaths(dirs) {
153
+ // Standard
154
+ _push(dirs, '/usr/local/bin');
155
+ _push(dirs, '/usr/bin');
156
+
157
+ // npm global (varies by install method)
158
+ _push(dirs, path.join(HOME, '.npm-global', 'bin'));
159
+
160
+ // nvm
161
+ const nvmDir = process.env.NVM_DIR || path.join(HOME, '.nvm');
162
+ try {
163
+ // Find current nvm version
164
+ const defaultPath = path.join(nvmDir, 'alias', 'default');
165
+ if (fs.existsSync(defaultPath)) {
166
+ const version = fs.readFileSync(defaultPath, 'utf-8').trim();
167
+ // Resolve alias like 'lts/*' or version number
168
+ const resolved = _resolveNvmVersion(nvmDir, version);
169
+ if (resolved) _push(dirs, path.join(nvmDir, 'versions', 'node', resolved, 'bin'));
170
+ }
171
+ // Also try current symlink
172
+ _push(dirs, path.join(nvmDir, 'current', 'bin'));
173
+ } catch {}
174
+
175
+ // fnm
176
+ const fnmDir = process.env.FNM_DIR || path.join(HOME, '.fnm');
177
+ try {
178
+ const defaultDir = path.join(fnmDir, 'aliases', 'default');
179
+ if (fs.existsSync(defaultDir)) {
180
+ const target = fs.realpathSync(defaultDir);
181
+ _push(dirs, path.join(target, 'bin'));
182
+ }
183
+ } catch {}
184
+
185
+ // volta
186
+ const voltaHome = process.env.VOLTA_HOME || path.join(HOME, '.volta');
187
+ _push(dirs, path.join(voltaHome, 'bin'));
188
+
189
+ // pip/pipx user installs
190
+ _push(dirs, path.join(HOME, '.local', 'bin'));
191
+
192
+ // cargo
193
+ _push(dirs, path.join(HOME, '.cargo', 'bin'));
194
+ }
195
+
196
+ // ---- macOS-specific ----
197
+
198
+ function _addMacPaths(dirs) {
199
+ // Homebrew (Apple Silicon + Intel)
200
+ _push(dirs, '/opt/homebrew/bin');
201
+ _push(dirs, '/opt/homebrew/sbin');
202
+ _push(dirs, '/usr/local/bin');
203
+ _push(dirs, '/usr/local/sbin');
204
+
205
+ // MacPorts
206
+ _push(dirs, '/opt/local/bin');
207
+
208
+ // pkgx
209
+ _push(dirs, path.join(HOME, '.pkgx', 'bin'));
210
+ }
211
+
212
+ // ---- Helpers ----
213
+
214
+ function _push(arr, dir) {
215
+ if (dir) arr.push(dir);
216
+ }
217
+
218
+ function _resolveNvmVersion(nvmDir, alias) {
219
+ // Handle direct version like 'v22.14.0'
220
+ if (alias.startsWith('v')) {
221
+ return alias;
222
+ }
223
+ // Handle aliases like 'lts/*', 'lts/jod', 'default', numeric '22'
224
+ try {
225
+ // Try reading alias file
226
+ const aliasFile = path.join(nvmDir, 'alias', alias.replace('/', path.sep));
227
+ if (fs.existsSync(aliasFile)) {
228
+ const target = fs.readFileSync(aliasFile, 'utf-8').trim();
229
+ return _resolveNvmVersion(nvmDir, target);
230
+ }
231
+ } catch {}
232
+
233
+ // Try finding latest matching version in versions dir
234
+ try {
235
+ const versionsDir = path.join(nvmDir, 'versions', 'node');
236
+ if (fs.existsSync(versionsDir)) {
237
+ const versions = fs.readdirSync(versionsDir)
238
+ .filter(v => v.startsWith('v'))
239
+ .sort()
240
+ .reverse();
241
+ const match = versions.find(v => v.startsWith('v' + alias));
242
+ if (match) return match;
243
+ // Just return the latest
244
+ if (versions.length > 0) return versions[0];
245
+ }
246
+ } catch {}
247
+
248
+ return null;
249
+ }
250
+
251
+ module.exports = {
252
+ getExtraBinDirs,
253
+ getEnhancedPATH,
254
+ getEnhancedEnv,
255
+ whichBinary,
256
+ IS_WINDOWS,
257
+ IS_MACOS,
258
+ SEP,
259
+ };