@openagents-org/agent-launcher 0.1.0
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 +86 -0
- package/bin/agent-connector.js +4 -0
- package/package.json +42 -0
- package/registry.json +457 -0
- package/src/adapters/base.js +327 -0
- package/src/adapters/claude.js +420 -0
- package/src/adapters/codex.js +260 -0
- package/src/adapters/index.js +39 -0
- package/src/adapters/openclaw.js +264 -0
- package/src/adapters/utils.js +83 -0
- package/src/adapters/workspace-prompt.js +293 -0
- package/src/autostart.js +178 -0
- package/src/cli.js +556 -0
- package/src/config.js +322 -0
- package/src/daemon.js +666 -0
- package/src/env.js +111 -0
- package/src/index.js +205 -0
- package/src/installer.js +588 -0
- package/src/paths.js +276 -0
- package/src/registry.js +197 -0
- package/src/tui.js +540 -0
- package/src/utils.js +93 -0
- package/src/workspace-client.js +338 -0
package/src/installer.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync, exec } = require('child_process');
|
|
6
|
+
const { whichBinary, getEnhancedEnv } = require('./paths');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages installation and uninstallation of agent runtimes.
|
|
10
|
+
*
|
|
11
|
+
* Install markers are stored in two places for compatibility with the Python SDK:
|
|
12
|
+
* 1. ~/.openagents/installed_agents.json (JSON array of names)
|
|
13
|
+
* 2. ~/.openagents/installed/<name> (empty marker files)
|
|
14
|
+
*/
|
|
15
|
+
class Installer {
|
|
16
|
+
constructor(registry, configDir) {
|
|
17
|
+
this.registry = registry;
|
|
18
|
+
this.configDir = configDir;
|
|
19
|
+
this.markersFile = path.join(configDir, 'installed_agents.json');
|
|
20
|
+
this.markersDir = path.join(configDir, 'installed');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the current platform key: 'macos', 'linux', or 'windows'.
|
|
25
|
+
*/
|
|
26
|
+
static platform() {
|
|
27
|
+
const p = process.platform;
|
|
28
|
+
if (p === 'darwin') return 'macos';
|
|
29
|
+
if (p === 'win32') return 'windows';
|
|
30
|
+
return 'linux';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if an agent type is installed.
|
|
35
|
+
* Checks binary on PATH first, then marker files.
|
|
36
|
+
*/
|
|
37
|
+
isInstalled(agentType) {
|
|
38
|
+
if (this._whichBinary(agentType)) return true;
|
|
39
|
+
return this._hasMarker(agentType);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find the binary path for an agent type.
|
|
44
|
+
*/
|
|
45
|
+
which(agentType) {
|
|
46
|
+
return this._whichBinary(agentType);
|
|
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
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install an agent runtime.
|
|
79
|
+
* @returns {Promise<{success: boolean, output: string}>}
|
|
80
|
+
*/
|
|
81
|
+
async install(agentType) {
|
|
82
|
+
const entry = this.registry.getEntry(agentType);
|
|
83
|
+
if (!entry || !entry.install) {
|
|
84
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cmd = this._getInstallCommand(entry.install);
|
|
88
|
+
if (!cmd) {
|
|
89
|
+
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Use bundled node/npm if system npm not available
|
|
93
|
+
if (cmd.startsWith('npm install')) {
|
|
94
|
+
const args = cmd.replace('npm install', 'install');
|
|
95
|
+
cmd = this._resolveNpmCommand(args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const output = await this._execShell(cmd);
|
|
99
|
+
this._markInstalled(agentType);
|
|
100
|
+
return { success: true, output };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Install with streaming output via callback.
|
|
105
|
+
* @param {string} agentType
|
|
106
|
+
* @param {function(string)} onData - called with each chunk of output
|
|
107
|
+
* @returns {Promise<{success: boolean, command: string}>}
|
|
108
|
+
*/
|
|
109
|
+
async installStreaming(agentType, onData) {
|
|
110
|
+
const { spawn } = require('child_process');
|
|
111
|
+
const entry = this.registry.getEntry(agentType);
|
|
112
|
+
if (!entry || !entry.install) {
|
|
113
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let rawCmd = this._getInstallCommand(entry.install);
|
|
117
|
+
if (!rawCmd) {
|
|
118
|
+
throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Auto-install Node.js if this is an npm-based agent and Node.js is missing
|
|
122
|
+
if (rawCmd.startsWith('npm install') && !this.hasNodejs()) {
|
|
123
|
+
await this.installNodejs(onData);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Resolve npm command
|
|
127
|
+
let cmd = rawCmd;
|
|
128
|
+
if (rawCmd.startsWith('npm install')) {
|
|
129
|
+
const args = rawCmd.replace('npm install', 'install --loglevel=verbose');
|
|
130
|
+
cmd = this._resolveNpmCommand(args);
|
|
131
|
+
} else if (rawCmd.startsWith('pip install') || rawCmd.startsWith('pipx install')) {
|
|
132
|
+
cmd = rawCmd; // pip commands stay as-is
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (onData) onData(`$ ${cmd}\n\n`);
|
|
136
|
+
|
|
137
|
+
const env = this._buildShellEnv();
|
|
138
|
+
const shell = process.platform === 'win32'
|
|
139
|
+
? (process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe')
|
|
140
|
+
: true;
|
|
141
|
+
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
144
|
+
|
|
145
|
+
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
146
|
+
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
147
|
+
|
|
148
|
+
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d); });
|
|
149
|
+
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d); });
|
|
150
|
+
|
|
151
|
+
proc.on('error', (err) => reject(err));
|
|
152
|
+
proc.on('close', (code) => {
|
|
153
|
+
if (code === 0) {
|
|
154
|
+
this._markInstalled(agentType);
|
|
155
|
+
if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
|
|
156
|
+
resolve({ success: true, command: cmd });
|
|
157
|
+
} else {
|
|
158
|
+
const msg = `Install failed with exit code ${code}`;
|
|
159
|
+
if (onData) onData(`\n${msg}\n`);
|
|
160
|
+
reject(new Error(msg));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Uninstall an agent runtime.
|
|
168
|
+
* @returns {Promise<{success: boolean, output: string}>}
|
|
169
|
+
*/
|
|
170
|
+
async uninstall(agentType) {
|
|
171
|
+
const entry = this.registry.getEntry(agentType);
|
|
172
|
+
if (!entry || !entry.install) {
|
|
173
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const installCmd = this._getInstallCommand(entry.install);
|
|
177
|
+
const uninstallCmd = this._deriveUninstallCommand(installCmd);
|
|
178
|
+
if (!uninstallCmd) {
|
|
179
|
+
throw new Error(`Cannot derive uninstall command for ${agentType}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const output = await this._execShell(uninstallCmd);
|
|
183
|
+
this._markUninstalled(agentType);
|
|
184
|
+
return { success: true, output };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Uninstall with streaming output via callback.
|
|
189
|
+
*/
|
|
190
|
+
async uninstallStreaming(agentType, onData) {
|
|
191
|
+
const { spawn } = require('child_process');
|
|
192
|
+
const entry = this.registry.getEntry(agentType);
|
|
193
|
+
if (!entry || !entry.install) {
|
|
194
|
+
throw new Error(`No install definition for agent type: ${agentType}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const installCmd = this._getInstallCommand(entry.install);
|
|
198
|
+
let rawCmd = this._deriveUninstallCommand(installCmd);
|
|
199
|
+
if (!rawCmd) {
|
|
200
|
+
throw new Error(`Cannot derive uninstall command for ${agentType}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolve npm to use bundled node if system npm is not available
|
|
204
|
+
let cmd = rawCmd;
|
|
205
|
+
if (rawCmd.startsWith('npm uninstall')) {
|
|
206
|
+
const args = rawCmd.replace('npm uninstall', 'uninstall --loglevel=verbose');
|
|
207
|
+
cmd = this._resolveNpmCommand(args);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (onData) onData(`$ ${cmd}\n\n`);
|
|
211
|
+
|
|
212
|
+
const env = this._buildShellEnv();
|
|
213
|
+
const shell = process.platform === 'win32'
|
|
214
|
+
? (process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe')
|
|
215
|
+
: true;
|
|
216
|
+
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
const proc = spawn(cmd, [], { shell, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
219
|
+
|
|
220
|
+
if (proc.stdout) proc.stdout.setEncoding('utf-8');
|
|
221
|
+
if (proc.stderr) proc.stderr.setEncoding('utf-8');
|
|
222
|
+
|
|
223
|
+
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d); });
|
|
224
|
+
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d); });
|
|
225
|
+
|
|
226
|
+
proc.on('error', (err) => reject(err));
|
|
227
|
+
proc.on('close', (code) => {
|
|
228
|
+
if (code === 0) {
|
|
229
|
+
this._markUninstalled(agentType);
|
|
230
|
+
if (onData) onData(`\nDone! ${agentType} has been uninstalled.\n`);
|
|
231
|
+
resolve({ success: true, command: cmd });
|
|
232
|
+
} else {
|
|
233
|
+
const msg = `Uninstall failed with exit code ${code}`;
|
|
234
|
+
if (onData) onData(`\n${msg}\n`);
|
|
235
|
+
reject(new Error(msg));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get install command for current platform.
|
|
243
|
+
*/
|
|
244
|
+
_getInstallCommand(installCfg) {
|
|
245
|
+
const plat = Installer.platform();
|
|
246
|
+
return installCfg[plat] || installCfg.command || null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Derive uninstall command from install command.
|
|
251
|
+
*/
|
|
252
|
+
_deriveUninstallCommand(installCmd) {
|
|
253
|
+
if (!installCmd) return null;
|
|
254
|
+
|
|
255
|
+
// npm install -g <pkg> → npm uninstall -g <pkg>
|
|
256
|
+
if (installCmd.includes('npm install')) {
|
|
257
|
+
return installCmd
|
|
258
|
+
.replace('npm install -g', 'npm uninstall -g')
|
|
259
|
+
.replace('npm install', 'npm uninstall')
|
|
260
|
+
.replace(/@latest/g, '')
|
|
261
|
+
.replace(/@[\d.]+/g, '');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// pip install <pkg> → pip uninstall -y <pkg>
|
|
265
|
+
if (installCmd.includes('pip install') || installCmd.includes('pip3 install')) {
|
|
266
|
+
return installCmd
|
|
267
|
+
.replace('pip install', 'pip uninstall -y')
|
|
268
|
+
.replace('pip3 install', 'pip3 uninstall -y');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// pipx install <pkg> → pipx uninstall <pkg>
|
|
272
|
+
if (installCmd.includes('pipx install')) {
|
|
273
|
+
return installCmd.replace('pipx install', 'pipx uninstall');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Find a binary on PATH (delegates to paths.js for cross-platform detection).
|
|
281
|
+
*/
|
|
282
|
+
_whichBinary(agentType) {
|
|
283
|
+
const entry = this.registry.getEntry(agentType);
|
|
284
|
+
const binary = entry && entry.install ? entry.install.binary : agentType;
|
|
285
|
+
return whichBinary(binary);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// -- Markers --
|
|
289
|
+
|
|
290
|
+
_hasMarker(agentType) {
|
|
291
|
+
// Check per-agent marker file first (faster)
|
|
292
|
+
try {
|
|
293
|
+
if (fs.existsSync(path.join(this.markersDir, agentType))) return true;
|
|
294
|
+
} catch {}
|
|
295
|
+
|
|
296
|
+
// Check JSON markers file
|
|
297
|
+
try {
|
|
298
|
+
if (fs.existsSync(this.markersFile)) {
|
|
299
|
+
const data = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
300
|
+
if (Array.isArray(data) && data.includes(agentType)) return true;
|
|
301
|
+
}
|
|
302
|
+
} catch {}
|
|
303
|
+
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_markInstalled(agentType) {
|
|
308
|
+
// JSON file
|
|
309
|
+
try {
|
|
310
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
311
|
+
let markers = [];
|
|
312
|
+
try {
|
|
313
|
+
markers = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
314
|
+
if (!Array.isArray(markers)) markers = [];
|
|
315
|
+
} catch {}
|
|
316
|
+
if (!markers.includes(agentType)) {
|
|
317
|
+
markers.push(agentType);
|
|
318
|
+
markers.sort();
|
|
319
|
+
}
|
|
320
|
+
fs.writeFileSync(this.markersFile, JSON.stringify(markers), 'utf-8');
|
|
321
|
+
} catch {}
|
|
322
|
+
|
|
323
|
+
// Per-agent marker file
|
|
324
|
+
try {
|
|
325
|
+
fs.mkdirSync(this.markersDir, { recursive: true });
|
|
326
|
+
fs.writeFileSync(path.join(this.markersDir, agentType), '', 'utf-8');
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_markUninstalled(agentType) {
|
|
331
|
+
// JSON file
|
|
332
|
+
try {
|
|
333
|
+
if (fs.existsSync(this.markersFile)) {
|
|
334
|
+
let markers = JSON.parse(fs.readFileSync(this.markersFile, 'utf-8'));
|
|
335
|
+
if (Array.isArray(markers)) {
|
|
336
|
+
markers = markers.filter((m) => m !== agentType);
|
|
337
|
+
fs.writeFileSync(this.markersFile, JSON.stringify(markers), 'utf-8');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {}
|
|
341
|
+
|
|
342
|
+
// Per-agent marker file
|
|
343
|
+
try {
|
|
344
|
+
const markerFile = path.join(this.markersDir, agentType);
|
|
345
|
+
if (fs.existsSync(markerFile)) fs.unlinkSync(markerFile);
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// -- Shell env + exec --
|
|
350
|
+
|
|
351
|
+
_buildShellEnv() {
|
|
352
|
+
const env = { ...process.env };
|
|
353
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
354
|
+
const extraDirs = [];
|
|
355
|
+
try { extraDirs.push(path.dirname(process.execPath)); } catch {}
|
|
356
|
+
|
|
357
|
+
// Check for bundled Node.js in ~/.openagents/nodejs/
|
|
358
|
+
if (process.platform === 'win32') {
|
|
359
|
+
try {
|
|
360
|
+
const bundledDir = path.join(this.configDir, 'nodejs');
|
|
361
|
+
if (fs.existsSync(bundledDir)) {
|
|
362
|
+
const entries = fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'));
|
|
363
|
+
if (entries.length > 0) {
|
|
364
|
+
extraDirs.push(path.join(bundledDir, entries[0]));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch {}
|
|
368
|
+
const appData = env.APPDATA || '';
|
|
369
|
+
if (appData) extraDirs.push(path.join(appData, 'npm'));
|
|
370
|
+
extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs') : 'C:\\Program Files\\nodejs');
|
|
371
|
+
extraDirs.push(env.SystemRoot ? path.join(env.SystemRoot, 'System32') : 'C:\\Windows\\System32');
|
|
372
|
+
extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'Git', 'cmd') : 'C:\\Program Files\\Git\\cmd');
|
|
373
|
+
} else {
|
|
374
|
+
extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
|
|
375
|
+
}
|
|
376
|
+
for (const d of extraDirs) {
|
|
377
|
+
if (d && !(env.PATH || '').includes(d)) {
|
|
378
|
+
env.PATH = d + sep + (env.PATH || '');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return env;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Check if Node.js/npm is available on the system.
|
|
386
|
+
*/
|
|
387
|
+
hasNodejs() {
|
|
388
|
+
if (whichBinary('node') && whichBinary('npm')) return true;
|
|
389
|
+
// Check bundled Node.js in ~/.openagents/nodejs/
|
|
390
|
+
if (process.platform === 'win32') {
|
|
391
|
+
try {
|
|
392
|
+
const bundledDir = path.join(this.configDir, 'nodejs');
|
|
393
|
+
if (fs.existsSync(bundledDir)) {
|
|
394
|
+
const entries = fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'));
|
|
395
|
+
if (entries.length > 0) {
|
|
396
|
+
const nodeExe = path.join(bundledDir, entries[0], 'node.exe');
|
|
397
|
+
return fs.existsSync(nodeExe);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {}
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Download and install Node.js LTS. Streams progress via onData callback.
|
|
407
|
+
* After install, updates PATH so npm is available for subsequent commands.
|
|
408
|
+
* @param {function(string)} onData
|
|
409
|
+
* @returns {Promise<void>}
|
|
410
|
+
*/
|
|
411
|
+
async installNodejs(onData) {
|
|
412
|
+
const { spawn: spawnProc } = require('child_process');
|
|
413
|
+
const https = require('https');
|
|
414
|
+
const os = require('os');
|
|
415
|
+
const nodeVersion = 'v22.14.0';
|
|
416
|
+
const plat = Installer.platform();
|
|
417
|
+
|
|
418
|
+
if (onData) onData(`Node.js not found. Installing Node.js ${nodeVersion}...\n\n`);
|
|
419
|
+
|
|
420
|
+
if (plat === 'windows') {
|
|
421
|
+
// Download portable zip — no admin required
|
|
422
|
+
const arch = os.arch() === 'x64' ? 'x64' : 'x86';
|
|
423
|
+
const url = `https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-win-${arch}.zip`;
|
|
424
|
+
const zipPath = path.join(os.tmpdir(), `node-${nodeVersion}.zip`);
|
|
425
|
+
|
|
426
|
+
if (onData) onData(`Downloading ${url}...\n`);
|
|
427
|
+
await this._downloadFile(url, zipPath, onData);
|
|
428
|
+
|
|
429
|
+
// Extract to ~/.openagents/nodejs/
|
|
430
|
+
const nodejsDir = path.join(this.configDir, 'nodejs');
|
|
431
|
+
fs.mkdirSync(nodejsDir, { recursive: true });
|
|
432
|
+
|
|
433
|
+
if (onData) onData(`\nExtracting Node.js to ${nodejsDir}...\n`);
|
|
434
|
+
await new Promise((resolve, reject) => {
|
|
435
|
+
const proc = spawnProc('powershell', [
|
|
436
|
+
'-NoProfile', '-Command',
|
|
437
|
+
`Expand-Archive -Path '${zipPath}' -DestinationPath '${nodejsDir}' -Force`
|
|
438
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
439
|
+
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
440
|
+
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
441
|
+
proc.on('error', reject);
|
|
442
|
+
proc.on('close', (code) => {
|
|
443
|
+
if (code === 0) resolve();
|
|
444
|
+
else reject(new Error(`Extraction failed with code ${code}`));
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// The zip extracts to node-vX.X.X-win-x64/ subfolder
|
|
449
|
+
const extractedDir = path.join(nodejsDir, `node-${nodeVersion}-win-${arch}`);
|
|
450
|
+
const sep = ';';
|
|
451
|
+
|
|
452
|
+
// Add extracted node dir to PATH for this session
|
|
453
|
+
if (!(process.env.PATH || '').includes(extractedDir)) {
|
|
454
|
+
process.env.PATH = extractedDir + sep + (process.env.PATH || '');
|
|
455
|
+
}
|
|
456
|
+
// npm global installs go to %APPDATA%\npm
|
|
457
|
+
const npmGlobal = path.join(process.env.APPDATA || '', 'npm');
|
|
458
|
+
if (npmGlobal && !(process.env.PATH || '').includes(npmGlobal)) {
|
|
459
|
+
process.env.PATH = npmGlobal + sep + process.env.PATH;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
} else {
|
|
463
|
+
// macOS / Linux: download portable tar.gz/tar.xz, extract to ~/.openagents/nodejs/
|
|
464
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
465
|
+
const ext = plat === 'macos' ? 'tar.gz' : 'tar.xz';
|
|
466
|
+
const platName = plat === 'macos' ? 'darwin' : 'linux';
|
|
467
|
+
const url = `https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-${platName}-${arch}.${ext}`;
|
|
468
|
+
const tarPath = path.join(os.tmpdir(), `node-${nodeVersion}.${ext}`);
|
|
469
|
+
|
|
470
|
+
if (onData) onData(`Downloading ${url}...\n`);
|
|
471
|
+
await this._downloadFile(url, tarPath, onData);
|
|
472
|
+
|
|
473
|
+
const nodeDir = path.join(this.configDir, 'nodejs');
|
|
474
|
+
fs.mkdirSync(nodeDir, { recursive: true });
|
|
475
|
+
|
|
476
|
+
if (onData) onData(`\nExtracting to ${nodeDir}...\n`);
|
|
477
|
+
const tarFlag = ext === 'tar.gz' ? '-xzf' : '-xJf';
|
|
478
|
+
await new Promise((resolve, reject) => {
|
|
479
|
+
const proc = spawnProc('tar', [tarFlag, tarPath, '-C', nodeDir, '--strip-components=1'], {
|
|
480
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
481
|
+
});
|
|
482
|
+
if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
483
|
+
if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d.toString()); });
|
|
484
|
+
proc.on('error', reject);
|
|
485
|
+
proc.on('close', (code) => {
|
|
486
|
+
if (code === 0) resolve();
|
|
487
|
+
else reject(new Error(`Extraction failed with code ${code}`));
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Add portable node bin to PATH
|
|
492
|
+
const nodeBin = path.join(nodeDir, 'bin');
|
|
493
|
+
if (!(process.env.PATH || '').includes(nodeBin)) {
|
|
494
|
+
process.env.PATH = nodeBin + ':' + (process.env.PATH || '');
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (onData) onData(`\nNode.js ${nodeVersion} installed successfully.\n\n`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Download a file with progress reporting.
|
|
503
|
+
*/
|
|
504
|
+
_downloadFile(url, destPath, onData) {
|
|
505
|
+
const https = require('https');
|
|
506
|
+
const http = require('http');
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const get = url.startsWith('https') ? https.get : http.get;
|
|
509
|
+
get(url, (res) => {
|
|
510
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
511
|
+
// Follow redirect
|
|
512
|
+
return this._downloadFile(res.headers.location, destPath, onData).then(resolve, reject);
|
|
513
|
+
}
|
|
514
|
+
if (res.statusCode !== 200) {
|
|
515
|
+
return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
516
|
+
}
|
|
517
|
+
const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
|
|
518
|
+
let downloaded = 0;
|
|
519
|
+
let lastPercent = -1;
|
|
520
|
+
const file = fs.createWriteStream(destPath);
|
|
521
|
+
res.on('data', (chunk) => {
|
|
522
|
+
downloaded += chunk.length;
|
|
523
|
+
if (totalBytes > 0) {
|
|
524
|
+
const pct = Math.floor((downloaded / totalBytes) * 100);
|
|
525
|
+
if (pct !== lastPercent && pct % 10 === 0) {
|
|
526
|
+
lastPercent = pct;
|
|
527
|
+
if (onData) onData(` ${pct}% (${(downloaded / 1024 / 1024).toFixed(1)} MB)\n`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
res.pipe(file);
|
|
532
|
+
file.on('finish', () => { file.close(); resolve(); });
|
|
533
|
+
file.on('error', reject);
|
|
534
|
+
}).on('error', reject);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Resolve the npm CLI command. Uses system npm if available.
|
|
540
|
+
*/
|
|
541
|
+
_resolveNpmCommand(args) {
|
|
542
|
+
const systemNpm = whichBinary('npm');
|
|
543
|
+
const npmBin = systemNpm ? `"${systemNpm}"` : 'npm';
|
|
544
|
+
|
|
545
|
+
// On macOS/Linux, use a user-writable prefix to avoid sudo for global installs
|
|
546
|
+
if (process.platform !== 'win32' && args.includes('-g')) {
|
|
547
|
+
const globalDir = path.join(this.configDir, 'npm-global');
|
|
548
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
549
|
+
// Add the bin dir to PATH so installed binaries are found
|
|
550
|
+
const binDir = path.join(globalDir, 'bin');
|
|
551
|
+
if (!(process.env.PATH || '').includes(binDir)) {
|
|
552
|
+
process.env.PATH = binDir + ':' + (process.env.PATH || '');
|
|
553
|
+
}
|
|
554
|
+
return `${npmBin} --prefix "${globalDir}" ${args}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return `${npmBin} ${args}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
_execShell(cmd, timeoutMs = 300000) {
|
|
561
|
+
return new Promise((resolve, reject) => {
|
|
562
|
+
const env = this._buildShellEnv();
|
|
563
|
+
|
|
564
|
+
let shell = true;
|
|
565
|
+
if (process.platform === 'win32') {
|
|
566
|
+
shell = env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
exec(cmd, {
|
|
570
|
+
encoding: 'utf-8',
|
|
571
|
+
timeout: timeoutMs,
|
|
572
|
+
shell,
|
|
573
|
+
env,
|
|
574
|
+
}, (error, stdout, stderr) => {
|
|
575
|
+
const output = ((stdout || '') + '\n' + (stderr || '')).trim();
|
|
576
|
+
if (error) {
|
|
577
|
+
const err = new Error(output || error.message);
|
|
578
|
+
err.exitCode = error.code;
|
|
579
|
+
reject(err);
|
|
580
|
+
} else {
|
|
581
|
+
resolve(output);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
module.exports = { Installer };
|