@openagents-org/agent-connector 0.2.6 → 0.2.8

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.8",
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": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Autostart — register agent-connector daemon as a system service.
3
+ *
4
+ * - macOS: launchd plist
5
+ * - Linux: systemd user unit
6
+ * - Windows: Task Scheduler XML
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+ const { whichBinary, IS_WINDOWS } = require('./paths');
15
+
16
+ const IS_MACOS = process.platform === 'darwin';
17
+ const IS_LINUX = process.platform === 'linux' && !IS_MACOS;
18
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
19
+
20
+ const SERVICE_LABEL = 'org.openagents.connector';
21
+
22
+ /**
23
+ * Enable autostart on login.
24
+ */
25
+ function enable(configDir) {
26
+ const nodeBin = whichBinary('node') || process.execPath;
27
+ const cliPath = path.resolve(__dirname, '..', 'bin', 'agent-connector.js');
28
+
29
+ if (IS_MACOS) return _enableMacOS(nodeBin, cliPath, configDir);
30
+ if (IS_LINUX) return _enableLinux(nodeBin, cliPath, configDir);
31
+ if (IS_WINDOWS) return _enableWindows(nodeBin, cliPath, configDir);
32
+ throw new Error(`Autostart not supported on ${process.platform}`);
33
+ }
34
+
35
+ /**
36
+ * Disable autostart.
37
+ */
38
+ function disable() {
39
+ if (IS_MACOS) return _disableMacOS();
40
+ if (IS_LINUX) return _disableLinux();
41
+ if (IS_WINDOWS) return _disableWindows();
42
+ throw new Error(`Autostart not supported on ${process.platform}`);
43
+ }
44
+
45
+ /**
46
+ * Check if autostart is enabled.
47
+ */
48
+ function isEnabled() {
49
+ if (IS_MACOS) {
50
+ const plistPath = path.join(HOME, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
51
+ return fs.existsSync(plistPath);
52
+ }
53
+ if (IS_LINUX) {
54
+ const unitPath = path.join(HOME, '.config', 'systemd', 'user', 'openagents-connector.service');
55
+ return fs.existsSync(unitPath);
56
+ }
57
+ if (IS_WINDOWS) {
58
+ try {
59
+ execSync(`schtasks /Query /TN "OpenAgents Connector"`, { stdio: 'pipe', timeout: 5000 });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // ---- macOS: launchd ----
69
+
70
+ function _enableMacOS(nodeBin, cliPath, configDir) {
71
+ const plistDir = path.join(HOME, 'Library', 'LaunchAgents');
72
+ fs.mkdirSync(plistDir, { recursive: true });
73
+
74
+ const plistPath = path.join(plistDir, `${SERVICE_LABEL}.plist`);
75
+ const logPath = path.join(configDir, 'daemon.log');
76
+
77
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
78
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
79
+ <plist version="1.0">
80
+ <dict>
81
+ <key>Label</key>
82
+ <string>${SERVICE_LABEL}</string>
83
+ <key>ProgramArguments</key>
84
+ <array>
85
+ <string>${nodeBin}</string>
86
+ <string>${cliPath}</string>
87
+ <string>up</string>
88
+ <string>--foreground</string>
89
+ </array>
90
+ <key>RunAtLoad</key>
91
+ <true/>
92
+ <key>KeepAlive</key>
93
+ <true/>
94
+ <key>StandardOutPath</key>
95
+ <string>${logPath}</string>
96
+ <key>StandardErrorPath</key>
97
+ <string>${logPath}</string>
98
+ <key>EnvironmentVariables</key>
99
+ <dict>
100
+ <key>PATH</key>
101
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
102
+ </dict>
103
+ </dict>
104
+ </plist>`;
105
+
106
+ fs.writeFileSync(plistPath, plist, 'utf-8');
107
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: 'pipe', timeout: 5000 });
108
+ return { enabled: true, path: plistPath };
109
+ }
110
+
111
+ function _disableMacOS() {
112
+ const plistPath = path.join(HOME, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
113
+ try {
114
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe', timeout: 5000 });
115
+ } catch {}
116
+ try { fs.unlinkSync(plistPath); } catch {}
117
+ return { enabled: false };
118
+ }
119
+
120
+ // ---- Linux: systemd user unit ----
121
+
122
+ function _enableLinux(nodeBin, cliPath, configDir) {
123
+ const unitDir = path.join(HOME, '.config', 'systemd', 'user');
124
+ fs.mkdirSync(unitDir, { recursive: true });
125
+
126
+ const unitPath = path.join(unitDir, 'openagents-connector.service');
127
+
128
+ const unit = `[Unit]
129
+ Description=OpenAgents Connector Daemon
130
+ After=network.target
131
+
132
+ [Service]
133
+ Type=simple
134
+ ExecStart=${nodeBin} ${cliPath} up --foreground
135
+ Restart=on-failure
136
+ RestartSec=10
137
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
138
+
139
+ [Install]
140
+ WantedBy=default.target
141
+ `;
142
+
143
+ fs.writeFileSync(unitPath, unit, 'utf-8');
144
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe', timeout: 5000 });
145
+ execSync('systemctl --user enable openagents-connector.service', { stdio: 'pipe', timeout: 5000 });
146
+ execSync('systemctl --user start openagents-connector.service', { stdio: 'pipe', timeout: 5000 });
147
+ return { enabled: true, path: unitPath };
148
+ }
149
+
150
+ function _disableLinux() {
151
+ try {
152
+ execSync('systemctl --user stop openagents-connector.service', { stdio: 'pipe', timeout: 5000 });
153
+ } catch {}
154
+ try {
155
+ execSync('systemctl --user disable openagents-connector.service', { stdio: 'pipe', timeout: 5000 });
156
+ } catch {}
157
+ const unitPath = path.join(HOME, '.config', 'systemd', 'user', 'openagents-connector.service');
158
+ try { fs.unlinkSync(unitPath); } catch {}
159
+ return { enabled: false };
160
+ }
161
+
162
+ // ---- Windows: Task Scheduler ----
163
+
164
+ function _enableWindows(nodeBin, cliPath, configDir) {
165
+ const taskName = 'OpenAgents Connector';
166
+ const cmd = `schtasks /Create /SC ONLOGON /TN "${taskName}" /TR "\\"${nodeBin}\\" \\"${cliPath}\\" up --foreground" /RL HIGHEST /F`;
167
+ execSync(cmd, { stdio: 'pipe', timeout: 10000 });
168
+ return { enabled: true, method: 'Task Scheduler' };
169
+ }
170
+
171
+ function _disableWindows() {
172
+ try {
173
+ execSync('schtasks /Delete /TN "OpenAgents Connector" /F', { stdio: 'pipe', timeout: 5000 });
174
+ } catch {}
175
+ return { enabled: false };
176
+ }
177
+
178
+ module.exports = { enable, disable, isEnabled };
package/src/cli.js CHANGED
@@ -327,6 +327,17 @@ async function cmdLogs(connector, flags, positional) {
327
327
  }
328
328
  }
329
329
 
330
+ async function cmdAutostart(connector, flags) {
331
+ const autostart = require('./autostart');
332
+ if (flags.disable) {
333
+ autostart.disable();
334
+ print('Autostart disabled.');
335
+ } else {
336
+ const result = autostart.enable(connector._config ? connector._config.configDir : require('path').join(require('os').homedir(), '.openagents'));
337
+ print(`Autostart enabled.${result.path ? ` Config: ${result.path}` : ''}`);
338
+ }
339
+ }
340
+
330
341
  async function cmdWorkspace(connector, flags, positional) {
331
342
  const sub = positional[0] || 'list';
332
343
  const subArgs = positional.slice(1);
@@ -461,6 +472,7 @@ Commands:
461
472
  connect <agent> <token> Connect agent to workspace
462
473
  disconnect <agent> Disconnect agent from workspace
463
474
  env <type> [--set K=V] View/set env vars for agent type
475
+ autostart [--disable] Enable/disable auto-start on login
464
476
  test-llm <type> Test LLM connection
465
477
  logs [agent] [--lines N] View daemon logs
466
478
  workspace create [name] Create a new workspace
@@ -502,6 +514,7 @@ async function main() {
502
514
  connect: () => cmdConnect(connector, flags, positional),
503
515
  disconnect: () => cmdDisconnect(connector, flags, positional),
504
516
  logs: () => cmdLogs(connector, flags, positional),
517
+ autostart: () => cmdAutostart(connector, flags),
505
518
  workspace: () => cmdWorkspace(connector, flags, positional),
506
519
  env: () => cmdEnv(connector, flags, positional),
507
520
  'test-llm': () => cmdTestLLM(connector, flags, positional),
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,38 +227,26 @@ 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
- }
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 || '');
242
236
  }
243
- for (const p of extraPaths) {
244
- if (p && !(env.PATH || '').includes(p)) {
245
- env.PATH = p + (process.platform === 'win32' ? ';' : ':') + (env.PATH || '');
246
- }
237
+
238
+ // On Windows, use cmd.exe explicitly with UTF-8 codepage
239
+ let shellCmd = cmd;
240
+ let shellOpt = true;
241
+ if (process.platform === 'win32') {
242
+ shellCmd = `cmd.exe /C "chcp 65001 >nul & ${cmd}"`;
243
+ shellOpt = false; // we're specifying the shell ourselves
247
244
  }
248
245
 
249
- exec(cmd, {
246
+ exec(shellCmd, {
250
247
  encoding: 'utf-8',
251
248
  timeout: timeoutMs,
252
- shell: true,
249
+ shell: shellOpt,
253
250
  env,
254
251
  }, (error, stdout, stderr) => {
255
252
  const output = ((stdout || '') + '\n' + (stderr || '')).trim();
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
+ };