robot-resources 1.0.0 → 1.1.1

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 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
- execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: 'utf-8' });
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 (systemd) ────────────────────────────────────────────────────────
184
+ // ─── Linux: systemd user service ────────────────────────────────────────────
127
185
 
128
- function getUnitPath() {
129
- return join(homedir(), '.config', 'systemd', 'user', 'robot-resources-router.service');
186
+ function getUserUnitPath() {
187
+ return join(homedir(), '.config', 'systemd', 'user', SERVICE_NAME);
130
188
  }
131
189
 
132
- function buildUnit(venvPythonPath) {
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
- ${envLines.join('\n')}
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 installSystemd(venvPythonPath) {
177
- const unitPath = getUnitPath();
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
- writeFileSync(unitPath, buildUnit(venvPythonPath));
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 uninstallSystemd() {
192
- const unitPath = getUnitPath();
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
- installSystemd(venvPythonPath);
217
- return { type: 'systemd', path: getUnitPath() };
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') return uninstallSystemd();
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('systemctl --user is-active robot-resources-router.service', { stdio: 'pipe' });
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(getUnitPath());
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
- success(`Router registered as ${svc.type} service`);
172
- info(`Config: ${svc.path}`);
173
- info('Router will start automatically on login and restart on crash');
174
- results.service = true;
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
- lines.push('✓ Authenticated with GitHub');
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.0.0",
3
+ "version": "1.1.1",
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
  }