robot-resources 1.0.0 → 1.1.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/lib/detect.js +5 -2
- package/lib/service.js +192 -36
- package/lib/wizard.js +18 -5
- package/package.json +5 -1
package/lib/detect.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
1
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -61,8 +61,11 @@ export function detectAgents() {
|
|
|
61
61
|
* Check if port 3838 is available.
|
|
62
62
|
*/
|
|
63
63
|
export function isPortAvailable(port = 3838) {
|
|
64
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
65
|
+
throw new Error('Invalid port number');
|
|
66
|
+
}
|
|
64
67
|
try {
|
|
65
|
-
|
|
68
|
+
execFileSync('lsof', ['-i', `:${port}`, '-t'], { encoding: 'utf-8', stdio: 'pipe' });
|
|
66
69
|
return false; // port is in use
|
|
67
70
|
} catch {
|
|
68
71
|
return true; // port is available (lsof returned non-zero = no process)
|
package/lib/service.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
5
|
import { readProviderKeys } from '@robot-resources/cli-core/config.mjs';
|
|
6
6
|
|
|
7
7
|
const LABEL = 'ai.robotresources.router';
|
|
8
|
+
const SERVICE_NAME = 'robot-resources-router.service';
|
|
8
9
|
const ROUTER_PORT = 3838;
|
|
9
10
|
|
|
10
11
|
// Maps config.json provider_keys names to environment variable names
|
|
@@ -14,6 +15,63 @@ const CONFIG_TO_ENV = {
|
|
|
14
15
|
google: 'GOOGLE_API_KEY',
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
// ─── Environment detection ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function isDocker() {
|
|
21
|
+
return existsSync('/.dockerenv') ||
|
|
22
|
+
(existsSync('/proc/1/cgroup') &&
|
|
23
|
+
readFileSync('/proc/1/cgroup', 'utf-8').includes('docker'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isWSL() {
|
|
27
|
+
try {
|
|
28
|
+
const version = readFileSync('/proc/version', 'utf-8');
|
|
29
|
+
return /microsoft|wsl/i.test(version);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hasSystemd() {
|
|
36
|
+
// systemd is PID 1 — check if /run/systemd/system exists (standard detection)
|
|
37
|
+
return existsSync('/run/systemd/system');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRoot() {
|
|
41
|
+
return process.getuid?.() === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Shared helpers ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function resolveProviderEnv() {
|
|
47
|
+
const configKeys = readProviderKeys();
|
|
48
|
+
const keyNames = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
49
|
+
const resolvedKeys = {};
|
|
50
|
+
for (const key of keyNames) {
|
|
51
|
+
if (process.env[key]) {
|
|
52
|
+
resolvedKeys[key] = process.env[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const [configName, envName] of Object.entries(CONFIG_TO_ENV)) {
|
|
56
|
+
if (!resolvedKeys[envName] && configKeys[configName]) {
|
|
57
|
+
resolvedKeys[envName] = configKeys[configName];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
resolvedKeys['PATH'] = '/usr/local/bin:/usr/bin:/bin';
|
|
61
|
+
return resolvedKeys;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeEnvFile(resolvedKeys) {
|
|
65
|
+
const envDir = join(homedir(), '.robot-resources');
|
|
66
|
+
const envPath = join(envDir, 'router.env');
|
|
67
|
+
mkdirSync(envDir, { recursive: true });
|
|
68
|
+
const lines = Object.entries(resolvedKeys)
|
|
69
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
70
|
+
.join('\n');
|
|
71
|
+
writeFileSync(envPath, lines + '\n', { mode: 0o600 });
|
|
72
|
+
return envPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
17
75
|
// ─── macOS (launchd) ────────────────────────────────────────────────────────
|
|
18
76
|
|
|
19
77
|
function getPlistPath() {
|
|
@@ -123,36 +181,16 @@ function isLaunchdRunning() {
|
|
|
123
181
|
}
|
|
124
182
|
}
|
|
125
183
|
|
|
126
|
-
// ─── Linux
|
|
184
|
+
// ─── Linux: systemd user service ────────────────────────────────────────────
|
|
127
185
|
|
|
128
|
-
function
|
|
129
|
-
return join(homedir(), '.config', 'systemd', 'user',
|
|
186
|
+
function getUserUnitPath() {
|
|
187
|
+
return join(homedir(), '.config', 'systemd', 'user', SERVICE_NAME);
|
|
130
188
|
}
|
|
131
189
|
|
|
132
|
-
function
|
|
190
|
+
function buildUserUnit(venvPythonPath, envFilePath) {
|
|
133
191
|
const home = homedir();
|
|
134
192
|
const logsDir = join(home, '.robot-resources', 'logs');
|
|
135
193
|
|
|
136
|
-
// Snapshot provider API keys: env vars take priority, then config.json
|
|
137
|
-
const envLines = [];
|
|
138
|
-
const configKeys = readProviderKeys();
|
|
139
|
-
const keyNames = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
140
|
-
const resolvedKeys = {};
|
|
141
|
-
for (const key of keyNames) {
|
|
142
|
-
if (process.env[key]) {
|
|
143
|
-
resolvedKeys[key] = process.env[key];
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
for (const [configName, envName] of Object.entries(CONFIG_TO_ENV)) {
|
|
147
|
-
if (!resolvedKeys[envName] && configKeys[configName]) {
|
|
148
|
-
resolvedKeys[envName] = configKeys[configName];
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
for (const [key, value] of Object.entries(resolvedKeys)) {
|
|
152
|
-
envLines.push(`Environment=${key}=${value}`);
|
|
153
|
-
}
|
|
154
|
-
envLines.push('Environment=PATH=/usr/local/bin:/usr/bin:/bin');
|
|
155
|
-
|
|
156
194
|
return `[Unit]
|
|
157
195
|
Description=Robot Resources Router — LLM cost optimization proxy
|
|
158
196
|
After=network-online.target
|
|
@@ -163,7 +201,7 @@ Type=simple
|
|
|
163
201
|
ExecStart=${venvPythonPath} -m robot_resources.cli.main start
|
|
164
202
|
Restart=on-failure
|
|
165
203
|
RestartSec=5
|
|
166
|
-
|
|
204
|
+
EnvironmentFile=${envFilePath}
|
|
167
205
|
WorkingDirectory=${home}/.robot-resources
|
|
168
206
|
StandardOutput=append:${logsDir}/router.stdout.log
|
|
169
207
|
StandardError=append:${logsDir}/router.stderr.log
|
|
@@ -173,23 +211,32 @@ WantedBy=default.target
|
|
|
173
211
|
`;
|
|
174
212
|
}
|
|
175
213
|
|
|
176
|
-
function
|
|
177
|
-
const unitPath =
|
|
214
|
+
function installSystemdUser(venvPythonPath) {
|
|
215
|
+
const unitPath = getUserUnitPath();
|
|
178
216
|
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
179
217
|
const unitDir = dirname(unitPath);
|
|
180
218
|
|
|
181
219
|
mkdirSync(logsDir, { recursive: true });
|
|
182
220
|
mkdirSync(unitDir, { recursive: true });
|
|
183
221
|
|
|
184
|
-
|
|
222
|
+
const resolvedKeys = resolveProviderEnv();
|
|
223
|
+
const envFilePath = writeEnvFile(resolvedKeys);
|
|
224
|
+
writeFileSync(unitPath, buildUserUnit(venvPythonPath, envFilePath));
|
|
185
225
|
chmodSync(unitPath, 0o600);
|
|
186
226
|
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
187
227
|
execSync('systemctl --user enable robot-resources-router.service', { stdio: 'pipe' });
|
|
188
228
|
execSync('systemctl --user start robot-resources-router.service', { stdio: 'pipe' });
|
|
229
|
+
|
|
230
|
+
// Enable linger so the service survives SSH disconnects (critical for VMs)
|
|
231
|
+
try {
|
|
232
|
+
execSync('loginctl enable-linger', { stdio: 'pipe' });
|
|
233
|
+
} catch {
|
|
234
|
+
// Non-fatal — linger may not be available (e.g. no loginctl)
|
|
235
|
+
}
|
|
189
236
|
}
|
|
190
237
|
|
|
191
|
-
function
|
|
192
|
-
const unitPath =
|
|
238
|
+
function uninstallSystemdUser() {
|
|
239
|
+
const unitPath = getUserUnitPath();
|
|
193
240
|
if (!existsSync(unitPath)) return;
|
|
194
241
|
|
|
195
242
|
try {
|
|
@@ -202,6 +249,74 @@ function uninstallSystemd() {
|
|
|
202
249
|
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
203
250
|
}
|
|
204
251
|
|
|
252
|
+
// ─── Linux: systemd system service (root / VMs / servers) ───────────────────
|
|
253
|
+
|
|
254
|
+
const SYSTEM_UNIT_PATH = `/etc/systemd/system/${SERVICE_NAME}`;
|
|
255
|
+
|
|
256
|
+
function buildSystemUnit(venvPythonPath, envFilePath) {
|
|
257
|
+
const home = homedir();
|
|
258
|
+
const logsDir = join(home, '.robot-resources', 'logs');
|
|
259
|
+
|
|
260
|
+
return `[Unit]
|
|
261
|
+
Description=Robot Resources Router — LLM cost optimization proxy
|
|
262
|
+
After=network-online.target
|
|
263
|
+
Wants=network-online.target
|
|
264
|
+
|
|
265
|
+
[Service]
|
|
266
|
+
Type=simple
|
|
267
|
+
User=root
|
|
268
|
+
ExecStart=${venvPythonPath} -m robot_resources.cli.main start
|
|
269
|
+
Restart=on-failure
|
|
270
|
+
RestartSec=5
|
|
271
|
+
EnvironmentFile=${envFilePath}
|
|
272
|
+
WorkingDirectory=${home}/.robot-resources
|
|
273
|
+
StandardOutput=append:${logsDir}/router.stdout.log
|
|
274
|
+
StandardError=append:${logsDir}/router.stderr.log
|
|
275
|
+
|
|
276
|
+
[Install]
|
|
277
|
+
WantedBy=multi-user.target
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function installSystemdSystem(venvPythonPath) {
|
|
282
|
+
const logsDir = join(homedir(), '.robot-resources', 'logs');
|
|
283
|
+
mkdirSync(logsDir, { recursive: true });
|
|
284
|
+
|
|
285
|
+
const resolvedKeys = resolveProviderEnv();
|
|
286
|
+
const envFilePath = writeEnvFile(resolvedKeys);
|
|
287
|
+
writeFileSync(SYSTEM_UNIT_PATH, buildSystemUnit(venvPythonPath, envFilePath));
|
|
288
|
+
chmodSync(SYSTEM_UNIT_PATH, 0o644);
|
|
289
|
+
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
290
|
+
execSync(`systemctl enable ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
291
|
+
execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function uninstallSystemdSystem() {
|
|
295
|
+
if (!existsSync(SYSTEM_UNIT_PATH)) return;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
299
|
+
execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
300
|
+
} catch {
|
|
301
|
+
// Already stopped
|
|
302
|
+
}
|
|
303
|
+
unlinkSync(SYSTEM_UNIT_PATH);
|
|
304
|
+
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Linux routing logic ────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function getLinuxMode() {
|
|
310
|
+
if (isDocker()) return 'docker';
|
|
311
|
+
if (isWSL() && !hasSystemd()) return 'wsl-no-systemd';
|
|
312
|
+
if (isRoot()) return 'system';
|
|
313
|
+
return 'user';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getLinuxUnitPath() {
|
|
317
|
+
return isRoot() ? SYSTEM_UNIT_PATH : getUserUnitPath();
|
|
318
|
+
}
|
|
319
|
+
|
|
205
320
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
206
321
|
|
|
207
322
|
/**
|
|
@@ -212,10 +327,38 @@ export function installService(venvPythonPath) {
|
|
|
212
327
|
installLaunchd(venvPythonPath);
|
|
213
328
|
return { type: 'launchd', path: getPlistPath() };
|
|
214
329
|
}
|
|
330
|
+
|
|
215
331
|
if (process.platform === 'linux') {
|
|
216
|
-
|
|
217
|
-
|
|
332
|
+
const mode = getLinuxMode();
|
|
333
|
+
|
|
334
|
+
if (mode === 'docker') {
|
|
335
|
+
return {
|
|
336
|
+
type: 'skipped',
|
|
337
|
+
reason: 'Running inside Docker — service registration skipped.\n' +
|
|
338
|
+
' Add this to your Dockerfile or entrypoint instead:\n' +
|
|
339
|
+
` ${venvPythonPath} -m robot_resources.cli.main start`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (mode === 'wsl-no-systemd') {
|
|
344
|
+
return {
|
|
345
|
+
type: 'skipped',
|
|
346
|
+
reason: 'WSL without systemd detected — service registration skipped.\n' +
|
|
347
|
+
' Enable systemd in WSL (wsl.conf → [boot] systemd=true) or run manually:\n' +
|
|
348
|
+
` ${venvPythonPath} -m robot_resources.cli.main start`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (mode === 'system') {
|
|
353
|
+
installSystemdSystem(venvPythonPath);
|
|
354
|
+
return { type: 'systemd-system', path: SYSTEM_UNIT_PATH };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// mode === 'user'
|
|
358
|
+
installSystemdUser(venvPythonPath);
|
|
359
|
+
return { type: 'systemd-user', path: getUserUnitPath() };
|
|
218
360
|
}
|
|
361
|
+
|
|
219
362
|
throw new Error(
|
|
220
363
|
`Service registration not supported on ${process.platform}.\n` +
|
|
221
364
|
` Run the router manually: rr-router start`
|
|
@@ -227,7 +370,11 @@ export function installService(venvPythonPath) {
|
|
|
227
370
|
*/
|
|
228
371
|
export function uninstallService() {
|
|
229
372
|
if (process.platform === 'darwin') return uninstallLaunchd();
|
|
230
|
-
if (process.platform === 'linux')
|
|
373
|
+
if (process.platform === 'linux') {
|
|
374
|
+
// Clean up whichever variant is installed
|
|
375
|
+
if (existsSync(SYSTEM_UNIT_PATH)) return uninstallSystemdSystem();
|
|
376
|
+
if (existsSync(getUserUnitPath())) return uninstallSystemdUser();
|
|
377
|
+
}
|
|
231
378
|
}
|
|
232
379
|
|
|
233
380
|
/**
|
|
@@ -236,8 +383,17 @@ export function uninstallService() {
|
|
|
236
383
|
export function isServiceRunning() {
|
|
237
384
|
if (process.platform === 'darwin') return isLaunchdRunning();
|
|
238
385
|
if (process.platform === 'linux') {
|
|
386
|
+
// Check system-level first, then user-level
|
|
387
|
+
if (existsSync(SYSTEM_UNIT_PATH)) {
|
|
388
|
+
try {
|
|
389
|
+
execSync(`systemctl is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
390
|
+
return true;
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
239
395
|
try {
|
|
240
|
-
execSync(
|
|
396
|
+
execSync(`systemctl --user is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
|
|
241
397
|
return true;
|
|
242
398
|
} catch {
|
|
243
399
|
return false;
|
|
@@ -251,7 +407,7 @@ export function isServiceRunning() {
|
|
|
251
407
|
*/
|
|
252
408
|
export function isServiceInstalled() {
|
|
253
409
|
if (process.platform === 'darwin') return existsSync(getPlistPath());
|
|
254
|
-
if (process.platform === 'linux') return existsSync(
|
|
410
|
+
if (process.platform === 'linux') return existsSync(SYSTEM_UNIT_PATH) || existsSync(getUserUnitPath());
|
|
255
411
|
return false;
|
|
256
412
|
}
|
|
257
413
|
|
package/lib/wizard.js
CHANGED
|
@@ -18,6 +18,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
18
18
|
|
|
19
19
|
const results = {
|
|
20
20
|
auth: false,
|
|
21
|
+
authMethod: null, // 'config' | 'apikey' | 'github'
|
|
21
22
|
router: false,
|
|
22
23
|
routerError: null,
|
|
23
24
|
providerKeys: false,
|
|
@@ -32,6 +33,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
32
33
|
if (config.api_key) {
|
|
33
34
|
success(`Already logged in as ${config.user_name || config.user_email || 'unknown'}`);
|
|
34
35
|
results.auth = true;
|
|
36
|
+
results.authMethod = 'config';
|
|
35
37
|
} else if (process.env.RR_API_KEY) {
|
|
36
38
|
// Agent flow: API key provided via environment variable
|
|
37
39
|
const envKey = process.env.RR_API_KEY;
|
|
@@ -42,6 +44,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
42
44
|
writeConfig({ api_key: envKey, signup_source: 'agent' });
|
|
43
45
|
success('API key loaded from RR_API_KEY environment variable');
|
|
44
46
|
results.auth = true;
|
|
47
|
+
results.authMethod = 'apikey';
|
|
45
48
|
}
|
|
46
49
|
} else {
|
|
47
50
|
step('Setting up your Robot Resources account...');
|
|
@@ -51,6 +54,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
51
54
|
try {
|
|
52
55
|
await login();
|
|
53
56
|
results.auth = true;
|
|
57
|
+
results.authMethod = 'github';
|
|
54
58
|
} catch (err) {
|
|
55
59
|
error(`Login failed: ${err.message}`);
|
|
56
60
|
info('You can log in later with: npx robot-resources login');
|
|
@@ -168,10 +172,15 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
168
172
|
|
|
169
173
|
try {
|
|
170
174
|
const svc = installService(getVenvPythonPath());
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
if (svc.type === 'skipped') {
|
|
176
|
+
warn(svc.reason);
|
|
177
|
+
results.service = false;
|
|
178
|
+
} else {
|
|
179
|
+
success(`Router registered as ${svc.type} service`);
|
|
180
|
+
info(`Config: ${svc.path}`);
|
|
181
|
+
info('Router will start automatically and restart on crash');
|
|
182
|
+
results.service = true;
|
|
183
|
+
}
|
|
175
184
|
} catch (err) {
|
|
176
185
|
error(`Service registration failed: ${err.message}`);
|
|
177
186
|
info('You can start the router manually: rr-router start');
|
|
@@ -209,7 +218,11 @@ export async function runWizard({ nonInteractive = false } = {}) {
|
|
|
209
218
|
const lines = [];
|
|
210
219
|
|
|
211
220
|
if (results.auth) {
|
|
212
|
-
|
|
221
|
+
const authLabel =
|
|
222
|
+
results.authMethod === 'apikey' ? 'Authenticated with API key' :
|
|
223
|
+
results.authMethod === 'config' ? 'Authenticated (saved credentials)' :
|
|
224
|
+
'Authenticated with GitHub';
|
|
225
|
+
lines.push(`✓ ${authLabel}`);
|
|
213
226
|
} else {
|
|
214
227
|
lines.push('○ Not logged in (run: npx robot-resources login)');
|
|
215
228
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robot-resources",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Robot Resources — AI agent runtime tools. One command to install everything.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"url": "git+https://github.com/robot-resources/robot-resources.git",
|
|
43
43
|
"directory": "packages/cli"
|
|
44
44
|
},
|
|
45
|
+
"homepage": "https://robotresources.ai",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/robot-resources/robot-resources/issues"
|
|
48
|
+
},
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
|
47
51
|
}
|