robot-resources 1.9.6 → 1.10.2
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 +0 -1
- package/lib/detect.js +0 -53
- package/lib/tool-config.js +6 -5
- package/lib/wizard.js +104 -300
- package/package.json +2 -5
- package/lib/python-bridge.js +0 -38
- package/lib/service.js +0 -740
package/lib/service.js
DELETED
|
@@ -1,740 +0,0 @@
|
|
|
1
|
-
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join, dirname } from 'node:path';
|
|
5
|
-
import { readProviderKeys } from '@robot-resources/cli-core/config.mjs';
|
|
6
|
-
|
|
7
|
-
const LABEL = 'ai.robotresources.router';
|
|
8
|
-
const SERVICE_NAME = 'robot-resources-router.service';
|
|
9
|
-
const TASK_NAME = 'RobotResourcesRouter';
|
|
10
|
-
const ROUTER_PORT = 3838;
|
|
11
|
-
|
|
12
|
-
// Maps config.json provider_keys names to environment variable names
|
|
13
|
-
const CONFIG_TO_ENV = {
|
|
14
|
-
openai: 'OPENAI_API_KEY',
|
|
15
|
-
anthropic: 'ANTHROPIC_API_KEY',
|
|
16
|
-
google: 'GOOGLE_API_KEY',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
// ─── Environment detection ──────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
function isDocker() {
|
|
22
|
-
return existsSync('/.dockerenv') ||
|
|
23
|
-
(existsSync('/proc/1/cgroup') &&
|
|
24
|
-
readFileSync('/proc/1/cgroup', 'utf-8').includes('docker'));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function isWSL() {
|
|
28
|
-
try {
|
|
29
|
-
const version = readFileSync('/proc/version', 'utf-8');
|
|
30
|
-
return /microsoft|wsl/i.test(version);
|
|
31
|
-
} catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function hasSystemd() {
|
|
37
|
-
// systemd is PID 1 — check if /run/systemd/system exists (standard detection)
|
|
38
|
-
return existsSync('/run/systemd/system');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isRoot() {
|
|
42
|
-
return process.getuid?.() === 0;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ─── Shared helpers ─────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
function resolveProviderEnv() {
|
|
48
|
-
const configKeys = readProviderKeys();
|
|
49
|
-
const keyNames = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
50
|
-
const resolvedKeys = {};
|
|
51
|
-
for (const key of keyNames) {
|
|
52
|
-
if (process.env[key]) {
|
|
53
|
-
resolvedKeys[key] = process.env[key];
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
for (const [configName, envName] of Object.entries(CONFIG_TO_ENV)) {
|
|
57
|
-
if (!resolvedKeys[envName] && configKeys[configName]) {
|
|
58
|
-
resolvedKeys[envName] = configKeys[configName];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
// Windows inherits PATH from the user profile when schtasks fires the task;
|
|
62
|
-
// overwriting it to a Unix path would break every `python.exe` resolution.
|
|
63
|
-
if (process.platform !== 'win32') {
|
|
64
|
-
resolvedKeys['PATH'] = '/usr/local/bin:/usr/bin:/bin';
|
|
65
|
-
}
|
|
66
|
-
return resolvedKeys;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function writeEnvFile(resolvedKeys) {
|
|
70
|
-
const envDir = join(homedir(), '.robot-resources');
|
|
71
|
-
const envPath = join(envDir, 'router.env');
|
|
72
|
-
mkdirSync(envDir, { recursive: true });
|
|
73
|
-
const lines = Object.entries(resolvedKeys)
|
|
74
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
75
|
-
.join('\n');
|
|
76
|
-
writeFileSync(envPath, lines + '\n', { mode: 0o600 });
|
|
77
|
-
return envPath;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ─── macOS (launchd) ────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
function getPlistPath() {
|
|
83
|
-
return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function buildPlist(venvPythonPath) {
|
|
87
|
-
const home = homedir();
|
|
88
|
-
const logsDir = join(home, '.robot-resources', 'logs');
|
|
89
|
-
|
|
90
|
-
// Snapshot provider API keys: env vars take priority, then config.json
|
|
91
|
-
const envVars = {};
|
|
92
|
-
const configKeys = readProviderKeys();
|
|
93
|
-
const keyNames = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
94
|
-
for (const key of keyNames) {
|
|
95
|
-
const value = process.env[key];
|
|
96
|
-
if (value) {
|
|
97
|
-
envVars[key] = value;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Fill in from config.json for any keys not found in environment
|
|
101
|
-
for (const [configName, envName] of Object.entries(CONFIG_TO_ENV)) {
|
|
102
|
-
if (!envVars[envName] && configKeys[configName]) {
|
|
103
|
-
envVars[envName] = configKeys[configName];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Ensure PATH includes common binary locations
|
|
107
|
-
envVars.PATH = '/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin';
|
|
108
|
-
|
|
109
|
-
const envEntries = Object.entries(envVars)
|
|
110
|
-
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
|
111
|
-
.join('\n');
|
|
112
|
-
|
|
113
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
114
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
115
|
-
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
116
|
-
<plist version="1.0">
|
|
117
|
-
<dict>
|
|
118
|
-
<key>Label</key>
|
|
119
|
-
<string>${LABEL}</string>
|
|
120
|
-
<key>ProgramArguments</key>
|
|
121
|
-
<array>
|
|
122
|
-
<string>${venvPythonPath}</string>
|
|
123
|
-
<string>-m</string>
|
|
124
|
-
<string>robot_resources.cli.main</string>
|
|
125
|
-
<string>start</string>
|
|
126
|
-
</array>
|
|
127
|
-
<key>RunAtLoad</key>
|
|
128
|
-
<true/>
|
|
129
|
-
<key>KeepAlive</key>
|
|
130
|
-
<true/>
|
|
131
|
-
<key>StandardOutPath</key>
|
|
132
|
-
<string>${logsDir}/router.stdout.log</string>
|
|
133
|
-
<key>StandardErrorPath</key>
|
|
134
|
-
<string>${logsDir}/router.stderr.log</string>
|
|
135
|
-
<key>EnvironmentVariables</key>
|
|
136
|
-
<dict>
|
|
137
|
-
${envEntries}
|
|
138
|
-
</dict>
|
|
139
|
-
<key>WorkingDirectory</key>
|
|
140
|
-
<string>${home}/.robot-resources</string>
|
|
141
|
-
</dict>
|
|
142
|
-
</plist>
|
|
143
|
-
`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function installLaunchd(venvPythonPath) {
|
|
147
|
-
const plistPath = getPlistPath();
|
|
148
|
-
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
149
|
-
const launchAgentsDir = join(homedir(), 'Library', 'LaunchAgents');
|
|
150
|
-
|
|
151
|
-
mkdirSync(logsDir, { recursive: true });
|
|
152
|
-
mkdirSync(launchAgentsDir, { recursive: true });
|
|
153
|
-
|
|
154
|
-
// Unload existing service if present
|
|
155
|
-
if (existsSync(plistPath)) {
|
|
156
|
-
try {
|
|
157
|
-
execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`, { stdio: 'pipe' });
|
|
158
|
-
} catch {
|
|
159
|
-
// Not loaded — fine
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
writeFileSync(plistPath, buildPlist(venvPythonPath));
|
|
164
|
-
chmodSync(plistPath, 0o600);
|
|
165
|
-
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`, { stdio: 'pipe' });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function uninstallLaunchd() {
|
|
169
|
-
const plistPath = getPlistPath();
|
|
170
|
-
if (!existsSync(plistPath)) return;
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`, { stdio: 'pipe' });
|
|
174
|
-
} catch {
|
|
175
|
-
// Already unloaded
|
|
176
|
-
}
|
|
177
|
-
unlinkSync(plistPath);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function isLaunchdRunning() {
|
|
181
|
-
try {
|
|
182
|
-
const output = execSync(`launchctl print gui/$(id -u)/${LABEL} 2>&1`, { encoding: 'utf-8' });
|
|
183
|
-
return output.includes('state = running');
|
|
184
|
-
} catch {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ─── Linux: systemd user service ────────────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
function getUserUnitPath() {
|
|
192
|
-
return join(homedir(), '.config', 'systemd', 'user', SERVICE_NAME);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function buildUserUnit(venvPythonPath, envFilePath) {
|
|
196
|
-
const home = homedir();
|
|
197
|
-
const logsDir = join(home, '.robot-resources', 'logs');
|
|
198
|
-
|
|
199
|
-
return `[Unit]
|
|
200
|
-
Description=Robot Resources Router — LLM cost optimization proxy
|
|
201
|
-
After=network-online.target
|
|
202
|
-
Wants=network-online.target
|
|
203
|
-
|
|
204
|
-
[Service]
|
|
205
|
-
Type=simple
|
|
206
|
-
ExecStart=${venvPythonPath} -m robot_resources.cli.main start
|
|
207
|
-
Restart=on-failure
|
|
208
|
-
RestartSec=5
|
|
209
|
-
EnvironmentFile=${envFilePath}
|
|
210
|
-
WorkingDirectory=${home}/.robot-resources
|
|
211
|
-
StandardOutput=append:${logsDir}/router.stdout.log
|
|
212
|
-
StandardError=append:${logsDir}/router.stderr.log
|
|
213
|
-
|
|
214
|
-
[Install]
|
|
215
|
-
WantedBy=default.target
|
|
216
|
-
`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Check whether linger is enabled for the current user.
|
|
221
|
-
*
|
|
222
|
-
* Without linger, systemd-user services are torn down when the user logs
|
|
223
|
-
* out (SSH disconnect, login manager logout). On the Finland signup
|
|
224
|
-
* (2026-04-23) this was the root cause: 3 heartbeats, then session ended,
|
|
225
|
-
* then the router died with the session.
|
|
226
|
-
*/
|
|
227
|
-
function isLingerEnabled() {
|
|
228
|
-
try {
|
|
229
|
-
const user = process.env.USER || process.env.LOGNAME;
|
|
230
|
-
if (!user) return false;
|
|
231
|
-
const res = spawnSync('loginctl', ['show-user', user, '--property=Linger'], {
|
|
232
|
-
stdio: 'pipe', encoding: 'utf-8',
|
|
233
|
-
});
|
|
234
|
-
if (res.status !== 0) return false;
|
|
235
|
-
return /^Linger=yes\s*$/m.test(res.stdout || '');
|
|
236
|
-
} catch {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function installSystemdUser(venvPythonPath) {
|
|
242
|
-
const unitPath = getUserUnitPath();
|
|
243
|
-
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
244
|
-
const unitDir = dirname(unitPath);
|
|
245
|
-
|
|
246
|
-
mkdirSync(logsDir, { recursive: true });
|
|
247
|
-
mkdirSync(unitDir, { recursive: true });
|
|
248
|
-
|
|
249
|
-
const resolvedKeys = resolveProviderEnv();
|
|
250
|
-
const envFilePath = writeEnvFile(resolvedKeys);
|
|
251
|
-
writeFileSync(unitPath, buildUserUnit(venvPythonPath, envFilePath));
|
|
252
|
-
chmodSync(unitPath, 0o600);
|
|
253
|
-
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
254
|
-
execSync('systemctl --user enable robot-resources-router.service', { stdio: 'pipe' });
|
|
255
|
-
execSync('systemctl --user start robot-resources-router.service', { stdio: 'pipe' });
|
|
256
|
-
|
|
257
|
-
// Enable linger so the service survives SSH disconnects (critical for VMs).
|
|
258
|
-
// On many distros this needs polkit auth and silently no-ops from a
|
|
259
|
-
// non-interactive shell — we attempt it then VERIFY the result.
|
|
260
|
-
let lingerEnabled = false;
|
|
261
|
-
try {
|
|
262
|
-
execSync('loginctl enable-linger', { stdio: 'pipe' });
|
|
263
|
-
} catch {
|
|
264
|
-
// fall through to verification
|
|
265
|
-
}
|
|
266
|
-
lingerEnabled = isLingerEnabled();
|
|
267
|
-
return { lingerEnabled };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function uninstallSystemdUser() {
|
|
271
|
-
const unitPath = getUserUnitPath();
|
|
272
|
-
if (!existsSync(unitPath)) return;
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
execSync('systemctl --user stop robot-resources-router.service', { stdio: 'pipe' });
|
|
276
|
-
execSync('systemctl --user disable robot-resources-router.service', { stdio: 'pipe' });
|
|
277
|
-
} catch {
|
|
278
|
-
// Already stopped
|
|
279
|
-
}
|
|
280
|
-
unlinkSync(unitPath);
|
|
281
|
-
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ─── Linux: systemd system service (root / VMs / servers) ───────────────────
|
|
285
|
-
|
|
286
|
-
const SYSTEM_UNIT_PATH = `/etc/systemd/system/${SERVICE_NAME}`;
|
|
287
|
-
|
|
288
|
-
function buildSystemUnit(venvPythonPath, envFilePath) {
|
|
289
|
-
const home = homedir();
|
|
290
|
-
const logsDir = join(home, '.robot-resources', 'logs');
|
|
291
|
-
|
|
292
|
-
return `[Unit]
|
|
293
|
-
Description=Robot Resources Router — LLM cost optimization proxy
|
|
294
|
-
After=network-online.target
|
|
295
|
-
Wants=network-online.target
|
|
296
|
-
|
|
297
|
-
[Service]
|
|
298
|
-
Type=simple
|
|
299
|
-
User=root
|
|
300
|
-
ExecStart=${venvPythonPath} -m robot_resources.cli.main start
|
|
301
|
-
Restart=on-failure
|
|
302
|
-
RestartSec=5
|
|
303
|
-
EnvironmentFile=${envFilePath}
|
|
304
|
-
WorkingDirectory=${home}/.robot-resources
|
|
305
|
-
StandardOutput=append:${logsDir}/router.stdout.log
|
|
306
|
-
StandardError=append:${logsDir}/router.stderr.log
|
|
307
|
-
|
|
308
|
-
[Install]
|
|
309
|
-
WantedBy=multi-user.target
|
|
310
|
-
`;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function installSystemdSystem(venvPythonPath) {
|
|
314
|
-
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
315
|
-
mkdirSync(logsDir, { recursive: true });
|
|
316
|
-
|
|
317
|
-
const resolvedKeys = resolveProviderEnv();
|
|
318
|
-
const envFilePath = writeEnvFile(resolvedKeys);
|
|
319
|
-
writeFileSync(SYSTEM_UNIT_PATH, buildSystemUnit(venvPythonPath, envFilePath));
|
|
320
|
-
chmodSync(SYSTEM_UNIT_PATH, 0o644);
|
|
321
|
-
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
322
|
-
execSync(`systemctl enable ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
323
|
-
execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function uninstallSystemdSystem() {
|
|
327
|
-
if (!existsSync(SYSTEM_UNIT_PATH)) return;
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
331
|
-
execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
332
|
-
} catch {
|
|
333
|
-
// Already stopped
|
|
334
|
-
}
|
|
335
|
-
unlinkSync(SYSTEM_UNIT_PATH);
|
|
336
|
-
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ─── Linux routing logic ────────────────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
function getLinuxMode() {
|
|
342
|
-
if (isDocker()) return 'docker';
|
|
343
|
-
if (isWSL() && !hasSystemd()) return 'wsl-no-systemd';
|
|
344
|
-
if (isRoot()) return 'system';
|
|
345
|
-
return 'user';
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function getLinuxUnitPath() {
|
|
349
|
-
return isRoot() ? SYSTEM_UNIT_PATH : getUserUnitPath();
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ─── Crontab @reboot fallback (Docker / WSL-no-systemd / rootless) ──────────
|
|
353
|
-
//
|
|
354
|
-
// When systemd isn't available, we fall back to a cron @reboot entry plus an
|
|
355
|
-
// immediate detached spawn. This gives the router:
|
|
356
|
-
// 1. Persistence across reboot — @reboot fires when cron starts at boot.
|
|
357
|
-
// 2. Immediate availability — the detached process starts right now.
|
|
358
|
-
//
|
|
359
|
-
// The crontab command invokes a wrapper script (same idea as the Windows .cmd
|
|
360
|
-
// wrapper) so env vars from router.env are sourced before launch. Cron runs
|
|
361
|
-
// commands with a minimal env so sourcing is required.
|
|
362
|
-
|
|
363
|
-
const CRONTAB_MARKER = '# robot-resources-router';
|
|
364
|
-
const CRONTAB_MARKER_FILE = '.crontab-installed';
|
|
365
|
-
|
|
366
|
-
function hasCrontab() {
|
|
367
|
-
const res = spawnSync('crontab', ['-l'], { stdio: 'pipe', encoding: 'utf-8' });
|
|
368
|
-
// Exit 0 = crontab exists; exit 1 with "no crontab" message = cron available
|
|
369
|
-
// but empty; exit 127 / ENOENT = no crontab command at all.
|
|
370
|
-
return res.error?.code !== 'ENOENT' && res.status !== 127;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function getCronWrapperPath() {
|
|
374
|
-
return join(homedir(), '.robot-resources', 'rr-router-run.sh');
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function getCrontabMarkerPath() {
|
|
378
|
-
return join(homedir(), '.robot-resources', CRONTAB_MARKER_FILE);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function buildCronWrapper(venvPythonPath, envFilePath, logsDir) {
|
|
382
|
-
// POSIX /bin/sh so it runs under any cron implementation (cron, cronie,
|
|
383
|
-
// busybox). `set -a` auto-exports every var we source. nohup detaches from
|
|
384
|
-
// cron's controlling terminal so the router stays alive after cron exits.
|
|
385
|
-
return [
|
|
386
|
-
'#!/bin/sh',
|
|
387
|
-
'set -a',
|
|
388
|
-
`. "${envFilePath}"`,
|
|
389
|
-
'set +a',
|
|
390
|
-
`exec nohup "${venvPythonPath}" -m robot_resources.cli.main start \\`,
|
|
391
|
-
` >> "${join(logsDir, 'router.stdout.log')}" \\`,
|
|
392
|
-
` 2>> "${join(logsDir, 'router.stderr.log')}"`,
|
|
393
|
-
'',
|
|
394
|
-
].join('\n');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function readCurrentCrontab() {
|
|
398
|
-
const res = spawnSync('crontab', ['-l'], { stdio: 'pipe', encoding: 'utf-8' });
|
|
399
|
-
// Exit 1 with no stdout usually means "no crontab for user" — treat as empty.
|
|
400
|
-
if (res.status !== 0) return '';
|
|
401
|
-
return res.stdout || '';
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function writeCrontab(contents) {
|
|
405
|
-
const res = spawnSync('crontab', ['-'], {
|
|
406
|
-
input: contents,
|
|
407
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
408
|
-
encoding: 'utf-8',
|
|
409
|
-
});
|
|
410
|
-
if (res.status !== 0) {
|
|
411
|
-
const stderr = (res.stderr || '').toString().trim();
|
|
412
|
-
throw new Error(`crontab write failed (status ${res.status}): ${stderr || 'unknown error'}`);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function spawnDetachedRouter(wrapperPath) {
|
|
417
|
-
// Immediate start so wizard's health check can verify :3838 right away.
|
|
418
|
-
// Best-effort: if cron or sh is unavailable, at least log it; the @reboot
|
|
419
|
-
// entry will still fire next boot.
|
|
420
|
-
const child = spawnSync('sh', ['-c', `nohup "${wrapperPath}" > /dev/null 2>&1 &`], {
|
|
421
|
-
stdio: 'ignore',
|
|
422
|
-
detached: true,
|
|
423
|
-
});
|
|
424
|
-
return child.status === 0;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function installCrontab(venvPythonPath) {
|
|
428
|
-
if (!hasCrontab()) {
|
|
429
|
-
throw new Error(
|
|
430
|
-
'crontab command not available — cannot install @reboot fallback.\n' +
|
|
431
|
-
` Run the router manually: ${venvPythonPath} -m robot_resources.cli.main start`,
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
436
|
-
mkdirSync(logsDir, { recursive: true });
|
|
437
|
-
|
|
438
|
-
// Snapshot provider env + PATH so the router has the keys it needs when
|
|
439
|
-
// cron fires at boot. Cron runs with a minimal env by default.
|
|
440
|
-
const resolvedKeys = resolveProviderEnv();
|
|
441
|
-
const envFilePath = writeEnvFile(resolvedKeys);
|
|
442
|
-
|
|
443
|
-
// Write wrapper script (0o700 — contains nothing secret itself, but only
|
|
444
|
-
// the owner should be able to execute it).
|
|
445
|
-
const wrapperPath = getCronWrapperPath();
|
|
446
|
-
writeFileSync(wrapperPath, buildCronWrapper(venvPythonPath, envFilePath, logsDir));
|
|
447
|
-
chmodSync(wrapperPath, 0o700);
|
|
448
|
-
|
|
449
|
-
// Add or replace our @reboot entry. Idempotent: strip any existing
|
|
450
|
-
// robot-resources lines first, then append a fresh one.
|
|
451
|
-
const current = readCurrentCrontab();
|
|
452
|
-
const filtered = current
|
|
453
|
-
.split('\n')
|
|
454
|
-
.filter((line) => !line.includes(CRONTAB_MARKER) && !line.includes('robot_resources.cli.main'))
|
|
455
|
-
.join('\n');
|
|
456
|
-
const trimmed = filtered.trimEnd();
|
|
457
|
-
const entry = `@reboot "${wrapperPath}" ${CRONTAB_MARKER}`;
|
|
458
|
-
const next = (trimmed ? trimmed + '\n' : '') + entry + '\n';
|
|
459
|
-
writeCrontab(next);
|
|
460
|
-
|
|
461
|
-
// Marker file lets uninstallService() know we used this fallback without
|
|
462
|
-
// having to re-parse the crontab.
|
|
463
|
-
writeFileSync(getCrontabMarkerPath(), new Date().toISOString() + '\n', { mode: 0o600 });
|
|
464
|
-
|
|
465
|
-
// Start now so :3838 is live before the wizard's health check runs.
|
|
466
|
-
spawnDetachedRouter(wrapperPath);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function uninstallCrontab() {
|
|
470
|
-
if (!existsSync(getCrontabMarkerPath())) return;
|
|
471
|
-
|
|
472
|
-
// Remove our line(s) from the user crontab; leave other entries alone.
|
|
473
|
-
if (hasCrontab()) {
|
|
474
|
-
try {
|
|
475
|
-
const current = readCurrentCrontab();
|
|
476
|
-
const filtered = current
|
|
477
|
-
.split('\n')
|
|
478
|
-
.filter((line) => !line.includes(CRONTAB_MARKER) && !line.includes('robot_resources.cli.main'))
|
|
479
|
-
.join('\n')
|
|
480
|
-
.trimEnd();
|
|
481
|
-
writeCrontab(filtered ? filtered + '\n' : '');
|
|
482
|
-
} catch {
|
|
483
|
-
// Best-effort — don't block uninstall if crontab write fails.
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Best-effort: kill the running router process.
|
|
488
|
-
spawnSync('pkill', ['-f', 'robot_resources.cli.main start'], { stdio: 'ignore' });
|
|
489
|
-
|
|
490
|
-
// Clean up marker + wrapper.
|
|
491
|
-
try { unlinkSync(getCrontabMarkerPath()); } catch { /* already gone */ }
|
|
492
|
-
try { unlinkSync(getCronWrapperPath()); } catch { /* already gone */ }
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function isCrontabInstalled() {
|
|
496
|
-
return existsSync(getCrontabMarkerPath());
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function isCrontabRouterRunning() {
|
|
500
|
-
// Cheapest check: is anything listening on :3838?
|
|
501
|
-
// We can't rely on `systemctl is-active` since we aren't using systemd.
|
|
502
|
-
const res = spawnSync('sh', ['-c', `lsof -i :${ROUTER_PORT} -t 2>/dev/null`], {
|
|
503
|
-
stdio: 'pipe',
|
|
504
|
-
encoding: 'utf-8',
|
|
505
|
-
});
|
|
506
|
-
return res.status === 0 && (res.stdout || '').trim().length > 0;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// ─── Windows (Task Scheduler) ───────────────────────────────────────────────
|
|
510
|
-
|
|
511
|
-
function getWrapperCmdPath() {
|
|
512
|
-
return join(homedir(), '.robot-resources', 'rr-router-run.cmd');
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function buildWrapperCmd(venvPythonPath, envFilePath, logsDir) {
|
|
516
|
-
// The wrapper sources router.env line-by-line into `set` (so quoting is
|
|
517
|
-
// handled by cmd.exe), then launches the router. stdout/stderr append to
|
|
518
|
-
// log files so the scheduled task — which has no attached console — leaves
|
|
519
|
-
// a trail the user can read.
|
|
520
|
-
return [
|
|
521
|
-
'@echo off',
|
|
522
|
-
'setlocal enabledelayedexpansion',
|
|
523
|
-
`for /f "usebackq tokens=* delims=" %%a in ("${envFilePath}") do set "%%a"`,
|
|
524
|
-
`"${venvPythonPath}" -m robot_resources.cli.main start >> "${join(logsDir, 'router.stdout.log')}" 2>> "${join(logsDir, 'router.stderr.log')}"`,
|
|
525
|
-
'',
|
|
526
|
-
].join('\r\n');
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function runSchtasks(args, { ignoreStatus = false } = {}) {
|
|
530
|
-
// spawnSync (not execSync) because paths like `C:\Users\Some Person\...`
|
|
531
|
-
// have spaces and shell re-tokenization via execSync is a quoting footgun.
|
|
532
|
-
// spawnSync with an arg array passes each arg verbatim to schtasks.exe.
|
|
533
|
-
const res = spawnSync('schtasks.exe', args, { stdio: 'pipe', encoding: 'utf-8' });
|
|
534
|
-
if (!ignoreStatus && res.status !== 0) {
|
|
535
|
-
const stderr = (res.stderr || '').toString().trim();
|
|
536
|
-
const stdout = (res.stdout || '').toString().trim();
|
|
537
|
-
throw new Error(`schtasks ${args[0]} failed (status ${res.status}): ${stderr || stdout || 'unknown error'}`);
|
|
538
|
-
}
|
|
539
|
-
return res;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function installTaskScheduler(venvPythonPath) {
|
|
543
|
-
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
544
|
-
mkdirSync(logsDir, { recursive: true });
|
|
545
|
-
|
|
546
|
-
const resolvedKeys = resolveProviderEnv();
|
|
547
|
-
const envFilePath = writeEnvFile(resolvedKeys);
|
|
548
|
-
const wrapperPath = getWrapperCmdPath();
|
|
549
|
-
writeFileSync(wrapperPath, buildWrapperCmd(venvPythonPath, envFilePath, logsDir));
|
|
550
|
-
|
|
551
|
-
// /rl LIMITED is the default — explicit here for readability. /f overwrites
|
|
552
|
-
// an existing task of the same name so the install is idempotent.
|
|
553
|
-
runSchtasks([
|
|
554
|
-
'/create', '/sc', 'onlogon',
|
|
555
|
-
'/tn', TASK_NAME,
|
|
556
|
-
'/tr', `"${wrapperPath}"`,
|
|
557
|
-
'/rl', 'LIMITED',
|
|
558
|
-
'/f',
|
|
559
|
-
]);
|
|
560
|
-
|
|
561
|
-
// Launch now so the wizard's post-install health check can hit port 3838.
|
|
562
|
-
// Best-effort — if the immediate run fails, the task is still registered
|
|
563
|
-
// and will fire at next logon.
|
|
564
|
-
runSchtasks(['/run', '/tn', TASK_NAME], { ignoreStatus: true });
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function uninstallTaskScheduler() {
|
|
568
|
-
runSchtasks(['/end', '/tn', TASK_NAME], { ignoreStatus: true });
|
|
569
|
-
runSchtasks(['/delete', '/tn', TASK_NAME, '/f'], { ignoreStatus: true });
|
|
570
|
-
if (existsSync(getWrapperCmdPath())) unlinkSync(getWrapperCmdPath());
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function isTaskSchedulerRunning() {
|
|
574
|
-
const res = spawnSync(
|
|
575
|
-
'schtasks.exe',
|
|
576
|
-
['/query', '/tn', TASK_NAME, '/fo', 'LIST', '/v'],
|
|
577
|
-
{ stdio: 'pipe', encoding: 'utf-8' },
|
|
578
|
-
);
|
|
579
|
-
if (res.status !== 0) return false;
|
|
580
|
-
// 0x41301 is TASK_RUNNING (HRESULT). Locale-independent — the localized
|
|
581
|
-
// "Running" / "In corso" / "実行中" string is unreliable across Windows SKUs.
|
|
582
|
-
return (res.stdout || '').includes('0x41301');
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function isTaskSchedulerInstalled() {
|
|
586
|
-
const res = spawnSync(
|
|
587
|
-
'schtasks.exe',
|
|
588
|
-
['/query', '/tn', TASK_NAME],
|
|
589
|
-
{ stdio: 'pipe' },
|
|
590
|
-
);
|
|
591
|
-
return res.status === 0;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Register the router as a system service and start it.
|
|
598
|
-
*/
|
|
599
|
-
export function installService(venvPythonPath) {
|
|
600
|
-
if (process.platform === 'darwin') {
|
|
601
|
-
installLaunchd(venvPythonPath);
|
|
602
|
-
return { type: 'launchd', path: getPlistPath() };
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (process.platform === 'linux') {
|
|
606
|
-
const mode = getLinuxMode();
|
|
607
|
-
|
|
608
|
-
// Docker and WSL-no-systemd fall through to the crontab fallback.
|
|
609
|
-
// Previously these returned { type: 'skipped' } and the router was never
|
|
610
|
-
// registered — users ended up with install_complete service=false and
|
|
611
|
-
// silent failure. crontab @reboot + immediate detached spawn gives us
|
|
612
|
-
// persistence across reboot without requiring systemd.
|
|
613
|
-
if (mode === 'docker' || mode === 'wsl-no-systemd') {
|
|
614
|
-
try {
|
|
615
|
-
installCrontab(venvPythonPath);
|
|
616
|
-
return { type: 'crontab', path: getCronWrapperPath() };
|
|
617
|
-
} catch (err) {
|
|
618
|
-
// Last-resort message if even cron is unavailable.
|
|
619
|
-
return {
|
|
620
|
-
type: 'skipped',
|
|
621
|
-
reason: `${mode === 'docker' ? 'Running inside Docker' : 'WSL without systemd'} and crontab fallback failed: ${err.message}\n` +
|
|
622
|
-
` Run the router manually: ${venvPythonPath} -m robot_resources.cli.main start`,
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
if (mode === 'system') {
|
|
628
|
-
installSystemdSystem(venvPythonPath);
|
|
629
|
-
return { type: 'systemd-system', path: SYSTEM_UNIT_PATH };
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// mode === 'user'
|
|
633
|
-
const { lingerEnabled } = installSystemdUser(venvPythonPath);
|
|
634
|
-
|
|
635
|
-
// Belt-and-suspenders: ALSO install crontab @reboot so the router comes
|
|
636
|
-
// back on reboot even if linger isn't taking effect (polkit denied,
|
|
637
|
-
// container restrictions, etc.). Idempotent — removes any existing
|
|
638
|
-
// RR crontab entry before adding the fresh one. Safe to call even
|
|
639
|
-
// when crontab is absent (we skip silently).
|
|
640
|
-
let crontabFallback = false;
|
|
641
|
-
if (hasCrontab()) {
|
|
642
|
-
try {
|
|
643
|
-
installCrontab(venvPythonPath);
|
|
644
|
-
crontabFallback = true;
|
|
645
|
-
} catch {
|
|
646
|
-
// Non-fatal — systemd-user still works while user is logged in.
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
return {
|
|
651
|
-
type: 'systemd-user',
|
|
652
|
-
path: getUserUnitPath(),
|
|
653
|
-
lingerEnabled,
|
|
654
|
-
crontabFallback,
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (process.platform === 'win32') {
|
|
659
|
-
installTaskScheduler(venvPythonPath);
|
|
660
|
-
return { type: 'schtasks', path: getWrapperCmdPath() };
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
throw new Error(
|
|
664
|
-
`Service registration not supported on ${process.platform}.\n` +
|
|
665
|
-
` Run the router manually: rr-router start`
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Stop and remove the router service.
|
|
671
|
-
*/
|
|
672
|
-
export function uninstallService() {
|
|
673
|
-
if (process.platform === 'darwin') return uninstallLaunchd();
|
|
674
|
-
if (process.platform === 'linux') {
|
|
675
|
-
// Clean up whatever variants are installed. systemd-user users may
|
|
676
|
-
// also have a crontab belt installed alongside — remove both.
|
|
677
|
-
if (existsSync(SYSTEM_UNIT_PATH)) uninstallSystemdSystem();
|
|
678
|
-
if (existsSync(getUserUnitPath())) uninstallSystemdUser();
|
|
679
|
-
if (isCrontabInstalled()) uninstallCrontab();
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
if (process.platform === 'win32') return uninstallTaskScheduler();
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Check if the router service is currently running.
|
|
687
|
-
*/
|
|
688
|
-
export function isServiceRunning() {
|
|
689
|
-
if (process.platform === 'darwin') return isLaunchdRunning();
|
|
690
|
-
if (process.platform === 'linux') {
|
|
691
|
-
// Check system-level first, then user-level
|
|
692
|
-
if (existsSync(SYSTEM_UNIT_PATH)) {
|
|
693
|
-
try {
|
|
694
|
-
execSync(`systemctl is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
695
|
-
return true;
|
|
696
|
-
} catch {
|
|
697
|
-
return false;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
if (existsSync(getUserUnitPath())) {
|
|
701
|
-
try {
|
|
702
|
-
execSync(`systemctl --user is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
703
|
-
return true;
|
|
704
|
-
} catch {
|
|
705
|
-
return false;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
// Crontab fallback — check if anything is listening on :3838.
|
|
709
|
-
if (isCrontabInstalled()) return isCrontabRouterRunning();
|
|
710
|
-
return false;
|
|
711
|
-
}
|
|
712
|
-
if (process.platform === 'win32') return isTaskSchedulerRunning();
|
|
713
|
-
return false;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* Check if a service config file exists.
|
|
718
|
-
*/
|
|
719
|
-
export function isServiceInstalled() {
|
|
720
|
-
if (process.platform === 'darwin') return existsSync(getPlistPath());
|
|
721
|
-
if (process.platform === 'linux') {
|
|
722
|
-
return existsSync(SYSTEM_UNIT_PATH) || existsSync(getUserUnitPath()) || isCrontabInstalled();
|
|
723
|
-
}
|
|
724
|
-
if (process.platform === 'win32') return isTaskSchedulerInstalled();
|
|
725
|
-
return false;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Get missing provider API keys (not in environment or config.json).
|
|
730
|
-
*/
|
|
731
|
-
export function getMissingProviderKeys() {
|
|
732
|
-
const keys = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
733
|
-
const configKeys = readProviderKeys();
|
|
734
|
-
return keys.filter((k) => {
|
|
735
|
-
if (process.env[k]) return false;
|
|
736
|
-
// Check config.json using the provider name mapping
|
|
737
|
-
const configName = Object.entries(CONFIG_TO_ENV).find(([, env]) => env === k)?.[0];
|
|
738
|
-
return !configName || !configKeys[configName];
|
|
739
|
-
});
|
|
740
|
-
}
|