robot-resources 1.8.2 → 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.
package/lib/service.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execSync, spawnSync } from 'node:child_process';
2
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';
@@ -6,6 +6,7 @@ import { readProviderKeys } from '@robot-resources/cli-core/config.mjs';
6
6
 
7
7
  const LABEL = 'ai.robotresources.router';
8
8
  const SERVICE_NAME = 'robot-resources-router.service';
9
+ const TASK_NAME = 'RobotResourcesRouter';
9
10
  const ROUTER_PORT = 3838;
10
11
 
11
12
  // Maps config.json provider_keys names to environment variable names
@@ -57,7 +58,11 @@ function resolveProviderEnv() {
57
58
  resolvedKeys[envName] = configKeys[configName];
58
59
  }
59
60
  }
60
- resolvedKeys['PATH'] = '/usr/local/bin:/usr/bin:/bin';
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
+ }
61
66
  return resolvedKeys;
62
67
  }
63
68
 
@@ -317,6 +322,248 @@ function getLinuxUnitPath() {
317
322
  return isRoot() ? SYSTEM_UNIT_PATH : getUserUnitPath();
318
323
  }
319
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
+
482
+ // ─── Windows (Task Scheduler) ───────────────────────────────────────────────
483
+
484
+ function getWrapperCmdPath() {
485
+ return join(homedir(), '.robot-resources', 'rr-router-run.cmd');
486
+ }
487
+
488
+ function buildWrapperCmd(venvPythonPath, envFilePath, logsDir) {
489
+ // The wrapper sources router.env line-by-line into `set` (so quoting is
490
+ // handled by cmd.exe), then launches the router. stdout/stderr append to
491
+ // log files so the scheduled task — which has no attached console — leaves
492
+ // a trail the user can read.
493
+ return [
494
+ '@echo off',
495
+ 'setlocal enabledelayedexpansion',
496
+ `for /f "usebackq tokens=* delims=" %%a in ("${envFilePath}") do set "%%a"`,
497
+ `"${venvPythonPath}" -m robot_resources.cli.main start >> "${join(logsDir, 'router.stdout.log')}" 2>> "${join(logsDir, 'router.stderr.log')}"`,
498
+ '',
499
+ ].join('\r\n');
500
+ }
501
+
502
+ function runSchtasks(args, { ignoreStatus = false } = {}) {
503
+ // spawnSync (not execSync) because paths like `C:\Users\Some Person\...`
504
+ // have spaces and shell re-tokenization via execSync is a quoting footgun.
505
+ // spawnSync with an arg array passes each arg verbatim to schtasks.exe.
506
+ const res = spawnSync('schtasks.exe', args, { stdio: 'pipe', encoding: 'utf-8' });
507
+ if (!ignoreStatus && res.status !== 0) {
508
+ const stderr = (res.stderr || '').toString().trim();
509
+ const stdout = (res.stdout || '').toString().trim();
510
+ throw new Error(`schtasks ${args[0]} failed (status ${res.status}): ${stderr || stdout || 'unknown error'}`);
511
+ }
512
+ return res;
513
+ }
514
+
515
+ function installTaskScheduler(venvPythonPath) {
516
+ const logsDir = join(homedir(), '.robot-resources', 'logs');
517
+ mkdirSync(logsDir, { recursive: true });
518
+
519
+ const resolvedKeys = resolveProviderEnv();
520
+ const envFilePath = writeEnvFile(resolvedKeys);
521
+ const wrapperPath = getWrapperCmdPath();
522
+ writeFileSync(wrapperPath, buildWrapperCmd(venvPythonPath, envFilePath, logsDir));
523
+
524
+ // /rl LIMITED is the default — explicit here for readability. /f overwrites
525
+ // an existing task of the same name so the install is idempotent.
526
+ runSchtasks([
527
+ '/create', '/sc', 'onlogon',
528
+ '/tn', TASK_NAME,
529
+ '/tr', `"${wrapperPath}"`,
530
+ '/rl', 'LIMITED',
531
+ '/f',
532
+ ]);
533
+
534
+ // Launch now so the wizard's post-install health check can hit port 3838.
535
+ // Best-effort — if the immediate run fails, the task is still registered
536
+ // and will fire at next logon.
537
+ runSchtasks(['/run', '/tn', TASK_NAME], { ignoreStatus: true });
538
+ }
539
+
540
+ function uninstallTaskScheduler() {
541
+ runSchtasks(['/end', '/tn', TASK_NAME], { ignoreStatus: true });
542
+ runSchtasks(['/delete', '/tn', TASK_NAME, '/f'], { ignoreStatus: true });
543
+ if (existsSync(getWrapperCmdPath())) unlinkSync(getWrapperCmdPath());
544
+ }
545
+
546
+ function isTaskSchedulerRunning() {
547
+ const res = spawnSync(
548
+ 'schtasks.exe',
549
+ ['/query', '/tn', TASK_NAME, '/fo', 'LIST', '/v'],
550
+ { stdio: 'pipe', encoding: 'utf-8' },
551
+ );
552
+ if (res.status !== 0) return false;
553
+ // 0x41301 is TASK_RUNNING (HRESULT). Locale-independent — the localized
554
+ // "Running" / "In corso" / "実行中" string is unreliable across Windows SKUs.
555
+ return (res.stdout || '').includes('0x41301');
556
+ }
557
+
558
+ function isTaskSchedulerInstalled() {
559
+ const res = spawnSync(
560
+ 'schtasks.exe',
561
+ ['/query', '/tn', TASK_NAME],
562
+ { stdio: 'pipe' },
563
+ );
564
+ return res.status === 0;
565
+ }
566
+
320
567
  // ─── Public API ─────────────────────────────────────────────────────────────
321
568
 
322
569
  /**
@@ -331,30 +578,23 @@ export function installService(venvPythonPath) {
331
578
  if (process.platform === 'linux') {
332
579
  const mode = getLinuxMode();
333
580
 
334
- if (mode === 'docker') {
335
- return {
336
- type: 'skipped',
337
- reason: 'Running inside Docker service registration skipped.\n' +
338
- ' Options:\n' +
339
- ' 1. Dockerfile entrypoint:\n' +
340
- ` CMD ["${venvPythonPath}", "-m", "robot_resources.cli.main", "start"]\n` +
341
- ' 2. Docker Compose sidecar:\n' +
342
- ' services:\n' +
343
- ' router:\n' +
344
- ` command: ${venvPythonPath} -m robot_resources.cli.main start\n` +
345
- ' ports: ["3838:3838"]\n' +
346
- ' 3. Background process:\n' +
347
- ` ${venvPythonPath} -m robot_resources.cli.main start &`,
348
- };
349
- }
350
-
351
- if (mode === 'wsl-no-systemd') {
352
- return {
353
- type: 'skipped',
354
- reason: 'WSL without systemd detected — service registration skipped.\n' +
355
- ' Enable systemd in WSL (wsl.conf → [boot] systemd=true) or run manually:\n' +
356
- ` ${venvPythonPath} -m robot_resources.cli.main start`,
357
- };
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
+ }
358
598
  }
359
599
 
360
600
  if (mode === 'system') {
@@ -367,6 +607,11 @@ export function installService(venvPythonPath) {
367
607
  return { type: 'systemd-user', path: getUserUnitPath() };
368
608
  }
369
609
 
610
+ if (process.platform === 'win32') {
611
+ installTaskScheduler(venvPythonPath);
612
+ return { type: 'schtasks', path: getWrapperCmdPath() };
613
+ }
614
+
370
615
  throw new Error(
371
616
  `Service registration not supported on ${process.platform}.\n` +
372
617
  ` Run the router manually: rr-router start`
@@ -379,10 +624,12 @@ export function installService(venvPythonPath) {
379
624
  export function uninstallService() {
380
625
  if (process.platform === 'darwin') return uninstallLaunchd();
381
626
  if (process.platform === 'linux') {
382
- // Clean up whichever variant is installed
627
+ // Clean up whichever variant is installed.
383
628
  if (existsSync(SYSTEM_UNIT_PATH)) return uninstallSystemdSystem();
384
629
  if (existsSync(getUserUnitPath())) return uninstallSystemdUser();
630
+ if (isCrontabInstalled()) return uninstallCrontab();
385
631
  }
632
+ if (process.platform === 'win32') return uninstallTaskScheduler();
386
633
  }
387
634
 
388
635
  /**
@@ -400,13 +647,19 @@ export function isServiceRunning() {
400
647
  return false;
401
648
  }
402
649
  }
403
- try {
404
- execSync(`systemctl --user is-active ${SERVICE_NAME}`, { stdio: 'pipe' });
405
- return true;
406
- } catch {
407
- 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
+ }
408
657
  }
658
+ // Crontab fallback — check if anything is listening on :3838.
659
+ if (isCrontabInstalled()) return isCrontabRouterRunning();
660
+ return false;
409
661
  }
662
+ if (process.platform === 'win32') return isTaskSchedulerRunning();
410
663
  return false;
411
664
  }
412
665
 
@@ -415,7 +668,10 @@ export function isServiceRunning() {
415
668
  */
416
669
  export function isServiceInstalled() {
417
670
  if (process.platform === 'darwin') return existsSync(getPlistPath());
418
- 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
+ }
674
+ if (process.platform === 'win32') return isTaskSchedulerInstalled();
419
675
  return false;
420
676
  }
421
677
 
package/lib/wizard.js CHANGED
@@ -162,9 +162,6 @@ export async function runWizard({ nonInteractive = false } = {}) {
162
162
  if (isServiceRunning()) {
163
163
  success('Router service already running');
164
164
  results.service = true;
165
- } else if (process.platform === 'win32') {
166
- warn('Windows detected — automatic service not supported');
167
- info('Run the router manually: rr-router start');
168
165
  } else {
169
166
  // Check port availability
170
167
  if (!isPortAvailable()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.8.2",
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": {