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.
- package/lib/service.js +189 -31
- 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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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')
|
|
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
|
}
|