robot-resources 1.9.5 → 1.9.7

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
@@ -216,6 +216,28 @@ WantedBy=default.target
216
216
  `;
217
217
  }
218
218
 
219
+ /**
220
+ * Check whether linger is enabled for the current user.
221
+ *
222
+ * Without linger, systemd-user services are torn down when the user logs
223
+ * out (SSH disconnect, login manager logout). On the Finland signup
224
+ * (2026-04-23) this was the root cause: 3 heartbeats, then session ended,
225
+ * then the router died with the session.
226
+ */
227
+ function isLingerEnabled() {
228
+ try {
229
+ const user = process.env.USER || process.env.LOGNAME;
230
+ if (!user) return false;
231
+ const res = spawnSync('loginctl', ['show-user', user, '--property=Linger'], {
232
+ stdio: 'pipe', encoding: 'utf-8',
233
+ });
234
+ if (res.status !== 0) return false;
235
+ return /^Linger=yes\s*$/m.test(res.stdout || '');
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
219
241
  function installSystemdUser(venvPythonPath) {
220
242
  const unitPath = getUserUnitPath();
221
243
  const logsDir = join(homedir(), '.robot-resources', 'logs');
@@ -232,12 +254,17 @@ function installSystemdUser(venvPythonPath) {
232
254
  execSync('systemctl --user enable robot-resources-router.service', { stdio: 'pipe' });
233
255
  execSync('systemctl --user start robot-resources-router.service', { stdio: 'pipe' });
234
256
 
235
- // Enable linger so the service survives SSH disconnects (critical for VMs)
257
+ // Enable linger so the service survives SSH disconnects (critical for VMs).
258
+ // On many distros this needs polkit auth and silently no-ops from a
259
+ // non-interactive shell — we attempt it then VERIFY the result.
260
+ let lingerEnabled = false;
236
261
  try {
237
262
  execSync('loginctl enable-linger', { stdio: 'pipe' });
238
263
  } catch {
239
- // Non-fatal linger may not be available (e.g. no loginctl)
264
+ // fall through to verification
240
265
  }
266
+ lingerEnabled = isLingerEnabled();
267
+ return { lingerEnabled };
241
268
  }
242
269
 
243
270
  function uninstallSystemdUser() {
@@ -603,8 +630,29 @@ export function installService(venvPythonPath) {
603
630
  }
604
631
 
605
632
  // mode === 'user'
606
- installSystemdUser(venvPythonPath);
607
- return { type: 'systemd-user', path: getUserUnitPath() };
633
+ const { lingerEnabled } = installSystemdUser(venvPythonPath);
634
+
635
+ // Belt-and-suspenders: ALSO install crontab @reboot so the router comes
636
+ // back on reboot even if linger isn't taking effect (polkit denied,
637
+ // container restrictions, etc.). Idempotent — removes any existing
638
+ // RR crontab entry before adding the fresh one. Safe to call even
639
+ // when crontab is absent (we skip silently).
640
+ let crontabFallback = false;
641
+ if (hasCrontab()) {
642
+ try {
643
+ installCrontab(venvPythonPath);
644
+ crontabFallback = true;
645
+ } catch {
646
+ // Non-fatal — systemd-user still works while user is logged in.
647
+ }
648
+ }
649
+
650
+ return {
651
+ type: 'systemd-user',
652
+ path: getUserUnitPath(),
653
+ lingerEnabled,
654
+ crontabFallback,
655
+ };
608
656
  }
609
657
 
610
658
  if (process.platform === 'win32') {
@@ -624,10 +672,12 @@ export function installService(venvPythonPath) {
624
672
  export function uninstallService() {
625
673
  if (process.platform === 'darwin') return uninstallLaunchd();
626
674
  if (process.platform === 'linux') {
627
- // Clean up whichever variant is installed.
628
- if (existsSync(SYSTEM_UNIT_PATH)) return uninstallSystemdSystem();
629
- if (existsSync(getUserUnitPath())) return uninstallSystemdUser();
630
- if (isCrontabInstalled()) return uninstallCrontab();
675
+ // Clean up whatever variants are installed. systemd-user users may
676
+ // also have a crontab belt installed alongside — remove both.
677
+ if (existsSync(SYSTEM_UNIT_PATH)) uninstallSystemdSystem();
678
+ if (existsSync(getUserUnitPath())) uninstallSystemdUser();
679
+ if (isCrontabInstalled()) uninstallCrontab();
680
+ return;
631
681
  }
632
682
  if (process.platform === 'win32') return uninstallTaskScheduler();
633
683
  }
package/lib/wizard.js CHANGED
@@ -9,6 +9,22 @@ 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
+ // Stamped onto every CLI telemetry payload so we can tell which `robot-resources`
14
+ // version a user actually ran. Without this, npx-cached old installers look
15
+ // identical to fresh runs in Supabase — which is exactly the visibility gap
16
+ // that left us blind on real-user install failures despite shipping rich
17
+ // diagnostics in PR #163. Read once at module load; safe to fail (telemetry
18
+ // just lands without the field).
19
+ const CLI_VERSION = (() => {
20
+ try {
21
+ return JSON.parse(
22
+ readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
23
+ ).version;
24
+ } catch {
25
+ return null;
26
+ }
27
+ })();
12
28
  /**
13
29
  * Classify an install error into a short reason code + bounded detail string.
14
30
  *
@@ -83,6 +99,8 @@ export async function runWizard({ nonInteractive = false } = {}) {
83
99
  // "pip installed but router never served a request" from a real
84
100
  // working setup in post-hoc telemetry.
85
101
  serviceType: null,
102
+ lingerEnabled: null,
103
+ crontabFallback: null,
86
104
  pluginInstalled: false,
87
105
  openclawDetected: false,
88
106
  openclawConfigPatched: false,
@@ -163,6 +181,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
163
181
  product: 'cli',
164
182
  event_type: 'wizard_started',
165
183
  payload: {
184
+ cli_version: CLI_VERSION,
166
185
  auth_method: results.authMethod,
167
186
  non_interactive: nonInteractive,
168
187
  },
@@ -235,12 +254,24 @@ export async function runWizard({ nonInteractive = false } = {}) {
235
254
  try {
236
255
  const svc = installService(getVenvPythonPath());
237
256
  results.serviceType = svc.type || null;
257
+ // systemd-user only survives user sessions with linger enabled; the
258
+ // installer now verifies the bit actually flipped and installs a
259
+ // crontab @reboot belt when it didn't. Capture both signals so we
260
+ // can tell which users land on a live-forever setup vs one that
261
+ // dies on logout.
262
+ results.lingerEnabled = svc.lingerEnabled ?? null;
263
+ results.crontabFallback = svc.crontabFallback ?? null;
238
264
  if (svc.type === 'skipped') {
239
265
  warn(svc.reason);
240
266
  results.service = false;
241
267
  } else {
242
268
  success(`Router registered as ${svc.type} service`);
243
269
  info(`Config: ${svc.path}`);
270
+ if (svc.type === 'systemd-user') {
271
+ if (svc.lingerEnabled) info('Linger enabled — router survives logout');
272
+ else warn('Linger not enabled — router may stop when you log out');
273
+ if (svc.crontabFallback) info('Crontab @reboot installed as fallback');
274
+ }
244
275
  info('Router will start automatically and restart on crash');
245
276
  results.service = true;
246
277
  }
@@ -410,6 +441,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
410
441
  // another event.
411
442
  const installPayload = {
412
443
  source: 'wizard',
444
+ cli_version: CLI_VERSION,
413
445
  router: results.router || false,
414
446
  service: results.service || false,
415
447
  scraper: results.scraper || false,
@@ -419,6 +451,8 @@ export async function runWizard({ nonInteractive = false } = {}) {
419
451
  install_duration_ms: Date.now() - wizardStartMs,
420
452
  python_source: results.pythonSource ?? null,
421
453
  service_type: results.serviceType ?? null,
454
+ linger_enabled: results.lingerEnabled,
455
+ crontab_fallback: results.crontabFallback,
422
456
  health_check: results.healthCheck,
423
457
  plugin_installed: results.pluginInstalled,
424
458
  openclaw_detected: results.openclawDetected,
@@ -540,10 +574,9 @@ export async function runWizard({ nonInteractive = false } = {}) {
540
574
  try {
541
575
  const statusDir = join(homedir(), '.robot-resources');
542
576
  mkdirSync(statusDir, { recursive: true });
543
- const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
544
577
  writeFileSync(join(statusDir, 'wizard-status.json'), JSON.stringify({
545
578
  completed_at: new Date().toISOString(),
546
- version: pkgVersion,
579
+ version: CLI_VERSION,
547
580
  router: results.router || false,
548
581
  service: results.service || false,
549
582
  scraper: results.scraper || false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.9.5",
3
+ "version": "1.9.7",
4
4
  "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {