demobite 1.18.4 → 1.19.0

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/launcher/index.mjs +73 -9
  2. package/package.json +1 -1
@@ -24,7 +24,8 @@ import fs from "node:fs";
24
24
  import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
- import { execFileSync, spawnSync } from "node:child_process";
27
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
28
+ import { createHash } from "node:crypto";
28
29
 
29
30
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
30
31
  const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
@@ -61,7 +62,14 @@ const LOCK = path.join(HOME, "listen.lock");
61
62
  const LOG = path.join(HOME, "listen.log");
62
63
  const STATE = path.join(HOME, "state.json");
63
64
  const CHILDREN = path.join(HOME, "children.json");
64
- const SERVICE_LABEL = "com.demobites.listen";
65
+ // A second home (DEMOBITE_HOME, used by tests and rigs) gets its own service name, so `off` on a rig can
66
+ // never unload the real one (2026-10-16: a scratch rig's `off` removed the founder's service).
67
+ const HOME_TAG = process.env.DEMOBITE_HOME ? "." + createHash("sha1").update(HOME).digest("hex").slice(0, 8) : "";
68
+ const SERVICE_LABEL = `com.demobites.listen${HOME_TAG}`;
69
+ const PKG_DIR = path.join(HOME, "pkg"); // the copy the service runs
70
+ const UPDATE = path.join(HOME, "update.json"); // { from, to, at, boots } while an update settles
71
+ const SERVICE_UNIT = `demobite${HOME_TAG}.service`;
72
+ const RUN_NAME = `DemoBites${HOME_TAG}`;
65
73
  const SERVICE = process.argv.includes("--service");
66
74
  const launcherPath = fileURLToPath(import.meta.url);
67
75
  const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
@@ -70,11 +78,11 @@ const pidAlive = (pid) => { if (!pid) return false; try { process.kill(pid, 0);
70
78
  const ago = (iso) => { const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`; };
71
79
  const serviceDef = () => {
72
80
  if (process.platform === "darwin") return { file: path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
73
- if (process.platform === "linux") return { file: path.join(os.homedir(), ".config", "systemd", "user", "demobite.service") };
81
+ if (process.platform === "linux") return { file: path.join(os.homedir(), ".config", "systemd", "user", SERVICE_UNIT) };
74
82
  return { file: path.join(HOME, "service.vbs") };
75
83
  };
76
84
  const serviceInstalled = () => {
77
- if (process.platform === "win32") { const r = spawnSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites"], { encoding: "utf8" }); return r.status === 0; }
85
+ if (process.platform === "win32") { const r = spawnSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", RUN_NAME], { encoding: "utf8" }); return r.status === 0; }
78
86
  return fs.existsSync(serviceDef().file);
79
87
  };
80
88
  const killListener = () => {
@@ -89,7 +97,7 @@ const printStatus = () => {
89
97
  const cfgThere = lock?.cwd ? readJson(path.join(lock.cwd, ".recorder", "config.json")) : null;
90
98
  const st = { base: cfgThere?.base ?? null, workspace: cfgThere?.workspace?.name ?? null, ...(readJson(STATE) ?? {}) };
91
99
  if (live) {
92
- console.log(` ● Connected as "${st?.name ?? os.hostname().replace(/\.local$/, "")}" since ${new Date(lock.since).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} · for ${st?.workspace ?? "your workspace"} on ${(st?.base ?? "").replace(/^https?:\/\//, "") || "app.demobites.com"} (pid ${lock.pid}${lock.service ? ", login service" : ", a Terminal window"})`);
100
+ console.log(` ● Connected as "${st?.name ?? os.hostname().replace(/\.local$/, "")}" since ${new Date(lock.since).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} · for ${st?.workspace ?? "your workspace"} on ${(st?.base ?? "").replace(/^https?:\/\//, "") || "app.demobites.com"} (pid ${lock.pid}${lock.service ? ", login service" : ", a Terminal window"}) · v${readJson(UPDATE)?.to ?? pkg.version}`);
93
101
  if (st?.at) console.log(` last check-in ${ago(st.at)} · ${st.busy ? `recording: ${st.job ?? ""}` : "idle"}${st.last_error ? ` · last error: ${st.last_error}` : ""}`);
94
102
  console.log(` working in ${lock.cwd.replace(os.homedir(), "~")}${lock.repo ? ` · code: ${lock.repo}` : ""}`);
95
103
  } else {
@@ -98,6 +106,7 @@ const printStatus = () => {
98
106
  console.log(` login service: ${serviceInstalled() ? "installed (starts at login, restarts if it dies)" : "not installed"} · run: demobite ${live ? "off" : "on"}`);
99
107
  console.log(` log: ${LOG.replace(os.homedir(), "~")}`);
100
108
  };
109
+ if (arg === "--version" || arg === "-v" || arg === "version") { console.log(pkg.version); process.exit(0); }
101
110
  if (arg === "status" && !process.argv[3]) { printStatus(); process.exit(0); }
102
111
  if (arg === "logs") {
103
112
  const n = Number(process.argv[3] || 40);
@@ -107,8 +116,8 @@ if (arg === "logs") {
107
116
  if (arg === "off") {
108
117
  const def = serviceDef();
109
118
  if (process.platform === "darwin") spawnSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE_LABEL}`], { stdio: "ignore" });
110
- else if (process.platform === "linux") spawnSync("systemctl", ["--user", "disable", "--now", "demobite.service"], { stdio: "ignore" });
111
- else spawnSync("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/f"], { stdio: "ignore" });
119
+ else if (process.platform === "linux") spawnSync("systemctl", ["--user", "disable", "--now", SERVICE_UNIT], { stdio: "ignore" });
120
+ else spawnSync("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", RUN_NAME, "/f"], { stdio: "ignore" });
112
121
  fs.rmSync(def.file, { force: true });
113
122
  const pid = killListener();
114
123
  // the browsers of an interrupted take go with it
@@ -322,14 +331,14 @@ if (arg === "on") {
322
331
  fs.writeFileSync(def.file, unit);
323
332
  killListener();
324
333
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
325
- const r = spawnSync("systemctl", ["--user", "enable", "--now", "demobite.service"], { encoding: "utf8" });
334
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", SERVICE_UNIT], { encoding: "utf8" });
326
335
  if (r.status !== 0) { warn(`systemd refused the service: ${(r.stderr || "").trim()}`); process.exit(1); }
327
336
  } else {
328
337
  // Windows: a Run entry that starts the listener hidden at logon (no console window), via a tiny script.
329
338
  const q = (t) => `""${t}""`;
330
339
  const vbs = `Set sh = CreateObject("WScript.Shell")\nsh.CurrentDirectory = "${cwd}"\nsh.Run "${args.map((a) => q(a)).join(" ")} >> ${q(LOG)} 2>&1", 0, False\n`;
331
340
  fs.writeFileSync(def.file, vbs);
332
- spawnSync("reg", ["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "DemoBites", "/t", "REG_SZ", "/d", `wscript.exe "${def.file}"`, "/f"], { stdio: "ignore" });
341
+ spawnSync("reg", ["add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", RUN_NAME, "/t", "REG_SZ", "/d", `wscript.exe "${def.file}"`, "/f"], { stdio: "ignore" });
333
342
  killListener();
334
343
  spawnSync("wscript.exe", [def.file], { stdio: "ignore" });
335
344
  }
@@ -457,6 +466,59 @@ if (arg === "listen") {
457
466
  const event = (job_id, kind, payload) => api({ action: "event", job_id, kind, payload });
458
467
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
459
468
 
469
+ // ── SELF-UPDATE (1.19.0, founder 2026-10-16: "checks if there's an update and updates itself") ──
470
+ // The service copy under ~/.demobite/pkg asks the registry once an hour (and at start) for the latest
471
+ // version. Newer, and idle: it installs that version into pkg.next with the private Node's npm, checks
472
+ // the new launcher answers `--version`, swaps the folders (the old copy stays in pkg.prev), and exits;
473
+ // the login service brings it back on the new code. A new copy that fails to check in three boots in
474
+ // a row is swapped back. Never during a job; a machine asleep for a week updates within the hour it wakes.
475
+ const selfUpdating = SERVICE && launcherPath.startsWith(PKG_DIR + path.sep);
476
+ const newer = (a, b) => { const A = a.split(".").map(Number), B = b.split(".").map(Number); for (let i = 0; i < 3; i++) { if ((A[i] || 0) !== (B[i] || 0)) return (A[i] || 0) > (B[i] || 0); } return false; };
477
+ const run = (cmd, args) => new Promise((resolve) => { const c = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; c.stdout.on("data", (d) => { out += d; }); c.stderr.on("data", (d) => { out += d; }); c.on("close", (code) => resolve({ code, out })); c.on("error", (e) => resolve({ code: -1, out: String(e) })); });
478
+ let updateCheckedAt = 0;
479
+ const selfUpdate = async () => {
480
+ if (!selfUpdating) return;
481
+ updateCheckedAt = Date.now();
482
+ let latest = null;
483
+ try { const r = await fetch(`https://registry.npmjs.org/${pkg.name}/latest`, { signal: AbortSignal.timeout(8000) }); if (r.ok) latest = (await r.json()).version; } catch { /* offline: next hour */ }
484
+ if (!latest || !newer(latest, pkg.version)) return;
485
+ const stamp = () => new Date().toISOString();
486
+ console.log(` ${stamp()} update: ${pkg.version} → ${latest}, installing…`);
487
+ const stage = PKG_DIR + ".next", prev = PKG_DIR + ".prev";
488
+ fs.rmSync(stage, { recursive: true, force: true });
489
+ // check-ins keep flowing while npm works, so the app never calls this machine quiet
490
+ const beat = setInterval(() => { void api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness() }); }, 5000);
491
+ const [cmd, args] = npmCmd(["install", "--prefix", stage, "--silent", "--no-audit", "--no-fund", `${pkg.name}@${latest}`]);
492
+ const r = await run(cmd, args);
493
+ clearInterval(beat);
494
+ if (r.code !== 0) { console.log(` ${stamp()} update: npm failed (${r.out.trim().split("\n").pop() || r.code}); staying on ${pkg.version}`); fs.rmSync(stage, { recursive: true, force: true }); return; }
495
+ const check = spawnSync(process.execPath, [path.join(stage, "node_modules", pkg.name, "launcher", "index.mjs"), "--version"], { encoding: "utf8", timeout: 20000 });
496
+ if (check.status !== 0 || check.stdout.trim() !== latest) { console.log(` ${stamp()} update: the new launcher did not answer; staying on ${pkg.version}`); fs.rmSync(stage, { recursive: true, force: true }); return; }
497
+ fs.rmSync(prev, { recursive: true, force: true });
498
+ fs.renameSync(PKG_DIR, prev);
499
+ fs.renameSync(stage, PKG_DIR);
500
+ writeJson(UPDATE, { from: pkg.version, to: latest, at: stamp(), boots: 0 });
501
+ console.log(` ${stamp()} update: ${latest} installed; restarting`);
502
+ if (process.platform === "win32") { const d = spawn("wscript.exe", [serviceDef().file], { detached: true, stdio: "ignore" }); d.unref(); }
503
+ bye("UPDATE");
504
+ };
505
+ // Three boots without a check-in on a fresh update: back to the copy that worked.
506
+ if (selfUpdating) {
507
+ const u = readJson(UPDATE);
508
+ if (u?.to === pkg.version) {
509
+ if ((u.boots ?? 0) >= 3 && fs.existsSync(PKG_DIR + ".prev")) {
510
+ console.log(` ${new Date().toISOString()} update: ${pkg.version} never checked in; back to ${u.from}`);
511
+ fs.rmSync(PKG_DIR + ".bad", { recursive: true, force: true });
512
+ fs.renameSync(PKG_DIR, PKG_DIR + ".bad"); fs.renameSync(PKG_DIR + ".prev", PKG_DIR);
513
+ fs.rmSync(UPDATE, { force: true });
514
+ if (process.platform === "win32") { const d = spawn("wscript.exe", [serviceDef().file], { detached: true, stdio: "ignore" }); d.unref(); }
515
+ bye("UPDATE");
516
+ }
517
+ writeJson(UPDATE, { ...u, boots: (u.boots ?? 0) + 1 });
518
+ }
519
+ }
520
+ const settled = () => { if (!selfUpdating) return; if (fs.existsSync(UPDATE)) { fs.rmSync(UPDATE, { force: true }); fs.rmSync(PKG_DIR + ".prev", { recursive: true, force: true }); console.log(` ${new Date().toISOString()} update: ${pkg.version} is in and checked in`); } };
521
+
460
522
  console.log(`\n Connected as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
461
523
  console.log(` Ask for a demo in the app (Create → Agentic Capture). ${engine === "claude" ? (agentBin ? `Claude Code${agentInfo.agent_version ? " " + agentInfo.agent_version : ""} plans the demos, on your own plan; they film on ${JOB_MODEL} unless you pick another model in the app.` : "! --engine=claude, but Claude Code was not found on this machine.") : `Demos are planned by the DemoBites agent on ${JOB_MODEL} (pick another model in the app)${process.env.DEMOBITES_NO_CODE ? "; code sharing is off" : repo ? "; the files it reads from this project are logged in each take" : ""}.`}`);
462
524
  // The one thing the person must know, boxed (founder 2026-10-15): this
@@ -597,7 +659,9 @@ if (arg === "listen") {
597
659
 
598
660
  for (;;) {
599
661
  const r = await api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness() });
662
+ if (r) settled();
600
663
  if (r?.job) await runJob(r.job);
664
+ else if (r && Date.now() - updateCheckedAt > 60 * 60 * 1000) await selfUpdate();
601
665
  else await sleep(5000);
602
666
  }
603
667
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "1.18.4",
3
+ "version": "1.19.0",
4
4
  "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"