robot-resources 1.9.0 → 1.9.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/service.js CHANGED
@@ -322,6 +322,163 @@ function getLinuxUnitPath() {
322
322
  return isRoot() ? SYSTEM_UNIT_PATH : getUserUnitPath();
323
323
  }
324
324
 
325
+ // ─── Crontab @reboot fallback (Docker / WSL-no-systemd / rootless) ──────────
326
+ //
327
+ // When systemd isn't available, we fall back to a cron @reboot entry plus an
328
+ // immediate detached spawn. This gives the router:
329
+ // 1. Persistence across reboot — @reboot fires when cron starts at boot.
330
+ // 2. Immediate availability — the detached process starts right now.
331
+ //
332
+ // The crontab command invokes a wrapper script (same idea as the Windows .cmd
333
+ // wrapper) so env vars from router.env are sourced before launch. Cron runs
334
+ // commands with a minimal env so sourcing is required.
335
+
336
+ const CRONTAB_MARKER = '# robot-resources-router';
337
+ const CRONTAB_MARKER_FILE = '.crontab-installed';
338
+
339
+ function hasCrontab() {
340
+ const res = spawnSync('crontab', ['-l'], { stdio: 'pipe', encoding: 'utf-8' });
341
+ // Exit 0 = crontab exists; exit 1 with "no crontab" message = cron available
342
+ // but empty; exit 127 / ENOENT = no crontab command at all.
343
+ return res.error?.code !== 'ENOENT' && res.status !== 127;
344
+ }
345
+
346
+ function getCronWrapperPath() {
347
+ return join(homedir(), '.robot-resources', 'rr-router-run.sh');
348
+ }
349
+
350
+ function getCrontabMarkerPath() {
351
+ return join(homedir(), '.robot-resources', CRONTAB_MARKER_FILE);
352
+ }
353
+
354
+ function buildCronWrapper(venvPythonPath, envFilePath, logsDir) {
355
+ // POSIX /bin/sh so it runs under any cron implementation (cron, cronie,
356
+ // busybox). `set -a` auto-exports every var we source. nohup detaches from
357
+ // cron's controlling terminal so the router stays alive after cron exits.
358
+ return [
359
+ '#!/bin/sh',
360
+ 'set -a',
361
+ `. "${envFilePath}"`,
362
+ 'set +a',
363
+ `exec nohup "${venvPythonPath}" -m robot_resources.cli.main start \\`,
364
+ ` >> "${join(logsDir, 'router.stdout.log')}" \\`,
365
+ ` 2>> "${join(logsDir, 'router.stderr.log')}"`,
366
+ '',
367
+ ].join('\n');
368
+ }
369
+
370
+ function readCurrentCrontab() {
371
+ const res = spawnSync('crontab', ['-l'], { stdio: 'pipe', encoding: 'utf-8' });
372
+ // Exit 1 with no stdout usually means "no crontab for user" — treat as empty.
373
+ if (res.status !== 0) return '';
374
+ return res.stdout || '';
375
+ }
376
+
377
+ function writeCrontab(contents) {
378
+ const res = spawnSync('crontab', ['-'], {
379
+ input: contents,
380
+ stdio: ['pipe', 'pipe', 'pipe'],
381
+ encoding: 'utf-8',
382
+ });
383
+ if (res.status !== 0) {
384
+ const stderr = (res.stderr || '').toString().trim();
385
+ throw new Error(`crontab write failed (status ${res.status}): ${stderr || 'unknown error'}`);
386
+ }
387
+ }
388
+
389
+ function spawnDetachedRouter(wrapperPath) {
390
+ // Immediate start so wizard's health check can verify :3838 right away.
391
+ // Best-effort: if cron or sh is unavailable, at least log it; the @reboot
392
+ // entry will still fire next boot.
393
+ const child = spawnSync('sh', ['-c', `nohup "${wrapperPath}" > /dev/null 2>&1 &`], {
394
+ stdio: 'ignore',
395
+ detached: true,
396
+ });
397
+ return child.status === 0;
398
+ }
399
+
400
+ function installCrontab(venvPythonPath) {
401
+ if (!hasCrontab()) {
402
+ throw new Error(
403
+ 'crontab command not available — cannot install @reboot fallback.\n' +
404
+ ` Run the router manually: ${venvPythonPath} -m robot_resources.cli.main start`,
405
+ );
406
+ }
407
+
408
+ const logsDir = join(homedir(), '.robot-resources', 'logs');
409
+ mkdirSync(logsDir, { recursive: true });
410
+
411
+ // Snapshot provider env + PATH so the router has the keys it needs when
412
+ // cron fires at boot. Cron runs with a minimal env by default.
413
+ const resolvedKeys = resolveProviderEnv();
414
+ const envFilePath = writeEnvFile(resolvedKeys);
415
+
416
+ // Write wrapper script (0o700 — contains nothing secret itself, but only
417
+ // the owner should be able to execute it).
418
+ const wrapperPath = getCronWrapperPath();
419
+ writeFileSync(wrapperPath, buildCronWrapper(venvPythonPath, envFilePath, logsDir));
420
+ chmodSync(wrapperPath, 0o700);
421
+
422
+ // Add or replace our @reboot entry. Idempotent: strip any existing
423
+ // robot-resources lines first, then append a fresh one.
424
+ const current = readCurrentCrontab();
425
+ const filtered = current
426
+ .split('\n')
427
+ .filter((line) => !line.includes(CRONTAB_MARKER) && !line.includes('robot_resources.cli.main'))
428
+ .join('\n');
429
+ const trimmed = filtered.trimEnd();
430
+ const entry = `@reboot "${wrapperPath}" ${CRONTAB_MARKER}`;
431
+ const next = (trimmed ? trimmed + '\n' : '') + entry + '\n';
432
+ writeCrontab(next);
433
+
434
+ // Marker file lets uninstallService() know we used this fallback without
435
+ // having to re-parse the crontab.
436
+ writeFileSync(getCrontabMarkerPath(), new Date().toISOString() + '\n', { mode: 0o600 });
437
+
438
+ // Start now so :3838 is live before the wizard's health check runs.
439
+ spawnDetachedRouter(wrapperPath);
440
+ }
441
+
442
+ function uninstallCrontab() {
443
+ if (!existsSync(getCrontabMarkerPath())) return;
444
+
445
+ // Remove our line(s) from the user crontab; leave other entries alone.
446
+ if (hasCrontab()) {
447
+ try {
448
+ const current = readCurrentCrontab();
449
+ const filtered = current
450
+ .split('\n')
451
+ .filter((line) => !line.includes(CRONTAB_MARKER) && !line.includes('robot_resources.cli.main'))
452
+ .join('\n')
453
+ .trimEnd();
454
+ writeCrontab(filtered ? filtered + '\n' : '');
455
+ } catch {
456
+ // Best-effort — don't block uninstall if crontab write fails.
457
+ }
458
+ }
459
+
460
+ // Best-effort: kill the running router process.
461
+ spawnSync('pkill', ['-f', 'robot_resources.cli.main start'], { stdio: 'ignore' });
462
+
463
+ // Clean up marker + wrapper.
464
+ try { unlinkSync(getCrontabMarkerPath()); } catch { /* already gone */ }
465
+ try { unlinkSync(getCronWrapperPath()); } catch { /* already gone */ }
466
+ }
467
+
468
+ function isCrontabInstalled() {
469
+ return existsSync(getCrontabMarkerPath());
470
+ }
471
+
472
+ function isCrontabRouterRunning() {
473
+ // Cheapest check: is anything listening on :3838?
474
+ // We can't rely on `systemctl is-active` since we aren't using systemd.
475
+ const res = spawnSync('sh', ['-c', `lsof -i :${ROUTER_PORT} -t 2>/dev/null`], {
476
+ stdio: 'pipe',
477
+ encoding: 'utf-8',
478
+ });
479
+ return res.status === 0 && (res.stdout || '').trim().length > 0;
480
+ }
481
+
325
482
  // ─── Windows (Task Scheduler) ───────────────────────────────────────────────
326
483
 
327
484
  function getWrapperCmdPath() {
@@ -421,30 +578,23 @@ export function installService(venvPythonPath) {
421
578
  if (process.platform === 'linux') {
422
579
  const mode = getLinuxMode();
423
580
 
424
- if (mode === 'docker') {
425
- return {
426
- type: 'skipped',
427
- reason: 'Running inside Docker service registration skipped.\n' +
428
- ' Options:\n' +
429
- ' 1. Dockerfile entrypoint:\n' +
430
- ` CMD ["${venvPythonPath}", "-m", "robot_resources.cli.main", "start"]\n` +
431
- ' 2. Docker Compose sidecar:\n' +
432
- ' services:\n' +
433
- ' router:\n' +
434
- ` command: ${venvPythonPath} -m robot_resources.cli.main start\n` +
435
- ' ports: ["3838:3838"]\n' +
436
- ' 3. Background process:\n' +
437
- ` ${venvPythonPath} -m robot_resources.cli.main start &`,
438
- };
439
- }
440
-
441
- if (mode === 'wsl-no-systemd') {
442
- return {
443
- type: 'skipped',
444
- reason: 'WSL without systemd detected — service registration skipped.\n' +
445
- ' Enable systemd in WSL (wsl.conf → [boot] systemd=true) or run manually:\n' +
446
- ` ${venvPythonPath} -m robot_resources.cli.main start`,
447
- };
581
+ // Docker and WSL-no-systemd fall through to the crontab fallback.
582
+ // Previously these returned { type: 'skipped' } and the router was never
583
+ // registered — users ended up with install_complete service=false and
584
+ // silent failure. crontab @reboot + immediate detached spawn gives us
585
+ // persistence across reboot without requiring systemd.
586
+ if (mode === 'docker' || mode === 'wsl-no-systemd') {
587
+ try {
588
+ installCrontab(venvPythonPath);
589
+ return { type: 'crontab', path: getCronWrapperPath() };
590
+ } catch (err) {
591
+ // Last-resort message if even cron is unavailable.
592
+ return {
593
+ type: 'skipped',
594
+ reason: `${mode === 'docker' ? 'Running inside Docker' : 'WSL without systemd'} and crontab fallback failed: ${err.message}\n` +
595
+ ` Run the router manually: ${venvPythonPath} -m robot_resources.cli.main start`,
596
+ };
597
+ }
448
598
  }
449
599
 
450
600
  if (mode === 'system') {
@@ -474,9 +624,10 @@ export function installService(venvPythonPath) {
474
624
  export function uninstallService() {
475
625
  if (process.platform === 'darwin') return uninstallLaunchd();
476
626
  if (process.platform === 'linux') {
477
- // Clean up whichever variant is installed
627
+ // Clean up whichever variant is installed.
478
628
  if (existsSync(SYSTEM_UNIT_PATH)) return uninstallSystemdSystem();
479
629
  if (existsSync(getUserUnitPath())) return uninstallSystemdUser();
630
+ if (isCrontabInstalled()) return uninstallCrontab();
480
631
  }
481
632
  if (process.platform === 'win32') return uninstallTaskScheduler();
482
633
  }
@@ -496,12 +647,17 @@ export function isServiceRunning() {
496
647
  return false;
497
648
  }
498
649
  }
499
- try {
500
- execSync(`systemctl --user is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
501
- return true;
502
- } catch {
503
- return false;
650
+ if (existsSync(getUserUnitPath())) {
651
+ try {
652
+ execSync(`systemctl --user is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
653
+ return true;
654
+ } catch {
655
+ return false;
656
+ }
504
657
  }
658
+ // Crontab fallback — check if anything is listening on :3838.
659
+ if (isCrontabInstalled()) return isCrontabRouterRunning();
660
+ return false;
505
661
  }
506
662
  if (process.platform === 'win32') return isTaskSchedulerRunning();
507
663
  return false;
@@ -512,7 +668,9 @@ export function isServiceRunning() {
512
668
  */
513
669
  export function isServiceInstalled() {
514
670
  if (process.platform === 'darwin') return existsSync(getPlistPath());
515
- if (process.platform === 'linux') return existsSync(SYSTEM_UNIT_PATH) || existsSync(getUserUnitPath());
671
+ if (process.platform === 'linux') {
672
+ return existsSync(SYSTEM_UNIT_PATH) || existsSync(getUserUnitPath()) || isCrontabInstalled();
673
+ }
516
674
  if (process.platform === 'win32') return isTaskSchedulerInstalled();
517
675
  return false;
518
676
  }
package/lib/wizard.js CHANGED
@@ -9,6 +9,40 @@ import { installService, isServiceRunning, isServiceInstalled } from './service.
9
9
  import { configureToolRouting, registerScraperMcp, restartOpenClawGateway } from './tool-config.js';
10
10
  import { checkHealth } from './health-report.js';
11
11
  import { header, step, success, warn, error, info, blank, summary } from './ui.js';
12
+ /**
13
+ * Classify an install error into a short reason code + bounded detail string.
14
+ *
15
+ * Before this existed, install_complete telemetry reported router:false with
16
+ * no context — 100% of rr-router installs failed and we couldn't diagnose.
17
+ * The reason code slots into a small enum so we can aggregate in the admin
18
+ * dashboard; detail is the tail of stderr/error message for deep-dives.
19
+ */
20
+ function classifyRouterError(err) {
21
+ const msg = (err?.message || String(err)).toLowerCase();
22
+ let reason = 'unknown';
23
+
24
+ if (msg.includes('python 3.10+') || msg.includes('python is required')) {
25
+ reason = 'python_not_found';
26
+ } else if (err?.code === 'ENOENT' || msg.includes('enoent')) {
27
+ reason = 'spawn_enoent';
28
+ } else if (msg.includes('timeout') || msg.includes('timed out') || err?.code === 'ETIMEDOUT') {
29
+ reason = 'timeout';
30
+ } else if (msg.includes('exited with code') || msg.includes('pip install')) {
31
+ reason = 'pip_install_failed';
32
+ } else if (msg.includes('permission denied') || err?.code === 'EACCES') {
33
+ reason = 'permission_denied';
34
+ } else if (msg.includes('disk') || msg.includes('space') || err?.code === 'ENOSPC') {
35
+ reason = 'disk_full';
36
+ } else if (msg.includes('network') || msg.includes('getaddrinfo') || msg.includes('enetunreach')) {
37
+ reason = 'network';
38
+ }
39
+
40
+ const rawDetail = err?.stderr?.trim?.() || err?.message || String(err);
41
+ const detail = rawDetail.slice(-500);
42
+
43
+ return { reason, detail, exitCode: err?.exitCode ?? null };
44
+ }
45
+
12
46
  /**
13
47
  * Main setup wizard. Handles the full onboarding flow:
14
48
  * 1. Router installation (Python venv + pip)
@@ -128,6 +162,8 @@ export async function runWizard({ nonInteractive = false } = {}) {
128
162
  warn('Python 3.10+ not found — skipping Router installation');
129
163
  info('Install Python from https://python.org and re-run this wizard');
130
164
  info('Scraper works without Python');
165
+ // Record the reason so install_complete tells us why router=false.
166
+ results.routerError = { reason: 'python_not_found', detail: 'Python 3.10+ not detected on PATH' };
131
167
  } else {
132
168
  info(`Found Python ${python.version} (${python.bin})`);
133
169
  step('Installing Router (this may take a moment)...');
@@ -138,7 +174,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
138
174
  results.router = true;
139
175
  } catch (err) {
140
176
  error(`Router installation failed: ${err.message}`);
141
- results.routerError = 'install-failed';
177
+ results.routerError = classifyRouterError(err);
142
178
  }
143
179
  }
144
180
  }
@@ -298,15 +334,21 @@ export async function runWizard({ nonInteractive = false } = {}) {
298
334
  try {
299
335
  const config = readConfig();
300
336
  const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
337
+ const installPayload = {
338
+ router: results.router || false,
339
+ service: results.service || false,
340
+ scraper: results.scraper || false,
341
+ source: 'wizard',
342
+ };
343
+ if (results.routerError && typeof results.routerError === 'object') {
344
+ installPayload.routerError = results.routerError.reason;
345
+ installPayload.routerErrorDetail = results.routerError.detail;
346
+ installPayload.platform = process.platform;
347
+ }
301
348
  const body = JSON.stringify({
302
349
  product: 'cli',
303
350
  event_type: 'install_complete',
304
- payload: {
305
- router: results.router || false,
306
- service: results.service || false,
307
- scraper: results.scraper || false,
308
- source: 'wizard',
309
- },
351
+ payload: installPayload,
310
352
  });
311
353
 
312
354
  for (let attempt = 0; attempt < 2; attempt++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {