/** * Auto-updater — once an hour, asks npm for the latest published version with a * single lightweight request. When a newer version exists AND the bot is fully * idle (no in-flight prompt, no other active Grok session on the PC), it * announces in chat, runs `npm install -g grok-telegram-bot@`, and * restarts to apply. After the restart it posts the new version's CHANGELOG * (tagged #update) so every release is easy to find in the conversation. * * Safety: * • only ever updates when idle — never interrupts a running turn/task; * • only for a global npm install (a cloned/source checkout is left alone); * • restart is supervisor-aware: under systemd/launchd it exits cleanly and * lets the supervisor relaunch; on Windows / foreground it re-execs itself. */ import { spawn } from "node:child_process"; import { get } from "node:https"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { JsonStore } from "./json-store.js"; import { markIntentionalShutdown } from "./lifetime-flag.js"; import { createLogger } from "../logger.js"; import { extractChangelog, isNewer, isSafeVersion } from "./version.js"; const log = createLogger("updater"); const PKG = "grok-telegram-bot"; interface PendingUpdate { from: string; to: string; chats: number[]; } export interface UpdaterOptions { enabled: boolean; intervalMs: number; projectRoot: string; instanceDir: string; dataDir: string; /** True while the agent is busy (a chat turn or scheduled task). */ isPromptInFlight: () => boolean; /** Active Grok sessions on this PC NOT owned by the bot's own agent. */ otherActiveSessions: () => number; /** Send a plain or markdown message to every chat. */ announce: (text: string, markdown: boolean) => Promise; /** Stop polling + the agent before the process exits/re-execs. */ shutdown: () => Promise; } export class Updater { private timer: NodeJS.Timeout | undefined; private readonly state: JsonStore; private readonly current: string; private attempting = false; private readonly tried = new Set(); constructor(private readonly opts: UpdaterOptions) { this.state = new JsonStore(join(opts.dataDir, "update-state.json"), null); this.current = readVersion(opts.projectRoot); } /** Announce a just-applied update (if any), then begin hourly checks. */ async start(): Promise { await this.announcePending(); if (!this.opts.enabled) { log.info("auto-update disabled (AUTO_UPDATE=false)"); return; } if (!this.isNpmInstall()) { log.info("running from source — auto-update is a no-op (use git to update)"); return; } // First check shortly after boot, then on the configured interval. this.timer = setTimeout(() => void this.tick(), 60_000); } stop(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; } private schedule(): void { this.timer = setTimeout(() => void this.tick(), this.opts.intervalMs); } private async tick(): Promise { try { await this.checkAndUpdate(); } catch (e) { log.debug("update check failed:", (e as Error).message); } finally { this.schedule(); } } private async checkAndUpdate(): Promise { if (this.attempting) return; const latest = await fetchLatestVersion(); if (!latest || !isSafeVersion(latest)) return; if (!isNewer(latest, this.current)) return; if (this.tried.has(latest)) return; // don't loop on a version we already tried if (this.opts.isPromptInFlight() || this.opts.otherActiveSessions() > 0) { log.info(`update ${this.current} -> ${latest} available; waiting for idle`); return; // re-evaluated next interval } await this.applyUpdate(latest); } private async applyUpdate(latest: string): Promise { this.attempting = true; this.tried.add(latest); log.info(`updating ${this.current} -> ${latest}`); await this.opts.announce( `\u{1F504} #update Updating ${PKG} v${this.current} \u2192 v${latest}\u2026\nThe bot is idle, so it's safe \u2014 it will restart and report what changed.`, false, ); const ok = await npmInstall(latest); if (!ok) { this.attempting = false; await this.opts.announce( `\u26A0\uFE0F #update Update to v${latest} failed (\`npm install -g\`). I'll try again after the next restart.`, false, ); return; } this.state.set({ from: this.current, to: latest, chats: this.announceChats() }); await this.restart(); } /** After a restart, post the new version's changelog (once), tagged #update. */ private async announcePending(): Promise { const pending = this.state.get(); if (!pending) return; this.state.set(null); // consume regardless, so we never re-announce if (pending.to !== this.current) { log.warn(`pending update to ${pending.to} but running ${this.current}; skipping announce`); return; } const notes = this.changelogFor(pending.to); const body = notes ? `\u{1F680} #update Updated v${pending.from} \u2192 **v${pending.to}**\n\n${notes}` : `\u{1F680} #update Updated to **v${pending.to}**.`; await this.opts.announce(body, true); } private async restart(): Promise { // Signal main/beforeExit that this exit is intentional (avoid keep-alive race). markIntentionalShutdown("updater-reexec"); await this.opts.shutdown().catch(() => {}); // Under systemd/launchd, a clean exit triggers a managed relaunch (no double // instance). On Windows / foreground there is no supervisor, so re-exec. if (process.env.GROK_TG_SUPERVISED === "1") { log.info("exiting for supervisor to relaunch the updated bot"); try { process.stderr.write("[updater] supervised exit for relaunch\n"); } catch { /* ignore */ } setTimeout(() => process.exit(0), 250); return; } log.info("re-executing the updated bot"); try { // TTY: inherit stdio so `npm start` doesn't look like a silent death. // Non-TTY (service): detach and ignore stdio. const inherit = Boolean(process.stdout.isTTY); const child = spawn( process.execPath, ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir], { detached: !inherit, stdio: inherit ? "inherit" : "ignore", cwd: this.opts.projectRoot, env: process.env, }, ); if (!inherit) child.unref(); if (!child.pid) { log.error("re-exec spawn produced no pid — staying alive"); return; } log.info(`re-exec child pid ${child.pid} (stdio=${inherit ? "inherit" : "ignore"})`); try { process.stderr.write(`[updater] re-exec child pid ${child.pid}; parent exiting\n`); } catch { /* ignore */ } } catch (e) { // Never exit if replacement failed — silent death is worse than stale code. log.error(`re-exec failed: ${(e as Error).message} — staying alive`); return; } setTimeout(() => process.exit(0), 500); } private isNpmInstall(): boolean { return this.opts.projectRoot.replace(/\\/g, "/").includes("/node_modules/"); } private announceChats(): number[] { const pending = this.state.get(); return pending?.chats ?? []; } private changelogFor(version: string): string { try { const md = readFileSync(join(this.opts.projectRoot, "CHANGELOG.md"), "utf-8"); return extractChangelog(md, version); } catch { return ""; } } } /** Read the installed version from package.json (falls back to "0.0.0"). */ function readVersion(projectRoot: string): string { try { return (JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8")) as { version?: string }).version ?? "0.0.0"; } catch { return "0.0.0"; } } /** One small HTTPS GET to the npm registry's dist-tag manifest for `latest`. */ function fetchLatestVersion(): Promise { return new Promise((resolve) => { const req = get( `https://registry.npmjs.org/${PKG}/latest`, { timeout: 10_000, headers: { Accept: "application/json" } }, (res) => { if (res.statusCode !== 200) { res.resume(); resolve(undefined); return; } let body = ""; res.setEncoding("utf-8"); res.on("data", (c) => (body += c)); res.on("end", () => { try { resolve((JSON.parse(body) as { version?: string }).version); } catch { resolve(undefined); } }); }, ); req.on("error", () => resolve(undefined)); req.on("timeout", () => { req.destroy(); resolve(undefined); }); }); } /** Run `npm install -g grok-telegram-bot@`; resolves true on success. */ function npmInstall(version: string): Promise { return new Promise((resolve) => { const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const child = spawn(npm, ["install", "-g", `${PKG}@${version}`], { stdio: "ignore" }); child.on("error", () => resolve(false)); child.on("exit", (code) => resolve(code === 0)); }); }