robot-resources 1.9.0 → 1.9.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.
Files changed (2) hide show
  1. package/lib/service.js +189 -31
  2. package/package.json +1 -1
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {