From ee97207ce84f5b54f56bc9edfbb7dc610d07e658 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 17:47:43 +0530 Subject: [PATCH 01/16] feat(cli): versioned install-layout path helpers + update-state --- .../src/lib/__tests__/install-layout.test.ts | 57 +++++++++++++++++ packages/cli/src/lib/install-layout.ts | 61 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 packages/cli/src/lib/__tests__/install-layout.test.ts create mode 100644 packages/cli/src/lib/install-layout.ts diff --git a/packages/cli/src/lib/__tests__/install-layout.test.ts b/packages/cli/src/lib/__tests__/install-layout.test.ts new file mode 100644 index 00000000..97a52910 --- /dev/null +++ b/packages/cli/src/lib/__tests__/install-layout.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, rmSync, mkdirSync, symlinkSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + distrotvHome, + versionsDir, + currentLink, + versionDir, + currentEntryPath, + readActiveVersion, + readUpdateState, + writeUpdateState, +} from "../install-layout.js" + +let home: string +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "dtv-")) + process.env.DISTROTV_HOME = home +}) +afterEach(() => { + delete process.env.DISTROTV_HOME + rmSync(home, { recursive: true, force: true }) +}) + +describe("install-layout", () => { + it("derives versioned paths from DISTROTV_HOME", () => { + expect(distrotvHome()).toBe(home) + expect(versionsDir()).toBe(join(home, "versions")) + expect(currentLink()).toBe(join(home, "current")) + expect(versionDir("0.2.11")).toBe(join(home, "versions", "0.2.11")) + expect(currentEntryPath()).toBe(join(home, "current", "dist", "index.js")) + }) + + it("readActiveVersion returns the current symlink target basename, or null", () => { + expect(readActiveVersion()).toBeNull() + mkdirSync(versionDir("0.2.11"), { recursive: true }) + symlinkSync(versionDir("0.2.11"), currentLink()) + expect(readActiveVersion()).toBe("0.2.11") + }) + + it("round-trips update-state", () => { + expect(readUpdateState()).toBeNull() + writeUpdateState({ + phase: "probation", + previousVersion: "0.2.10", + newVersion: "0.2.11", + swappedAt: 5, + }) + expect(readUpdateState()).toEqual({ + phase: "probation", + previousVersion: "0.2.10", + newVersion: "0.2.11", + swappedAt: 5, + }) + }) +}) diff --git a/packages/cli/src/lib/install-layout.ts b/packages/cli/src/lib/install-layout.ts new file mode 100644 index 00000000..d332da6e --- /dev/null +++ b/packages/cli/src/lib/install-layout.ts @@ -0,0 +1,61 @@ +import { randomBytes } from "node:crypto" +import { mkdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs" +import { basename, join } from "node:path" +import { homedir } from "node:os" + +export function distrotvHome(): string { + return process.env["DISTROTV_HOME"] ?? join(homedir(), ".distrotv") +} +export function versionsDir(): string { + return join(distrotvHome(), "versions") +} +export function currentLink(): string { + return join(distrotvHome(), "current") +} +export function versionDir(v: string): string { + return join(versionsDir(), v) +} +// stable path shims + hooks invoke; resolves through `current` on every call. +export function currentEntryPath(): string { + return join(currentLink(), "dist", "index.js") +} +export function updateStatePath(): string { + return join(distrotvHome(), "update-state.json") +} +export function badVersionsPath(): string { + return join(distrotvHome(), "bad-versions.json") +} + +export function readActiveVersion(): string | null { + try { + return basename(readlinkSync(currentLink())) + } catch { + return null + } +} + +export type UpdatePhase = "probation" | "stable" | "rolled-back" +export interface UpdateState { + phase: UpdatePhase + previousVersion: string | null + newVersion: string + swappedAt: number +} + +export function readUpdateState(): UpdateState | null { + try { + return JSON.parse(readFileSync(updateStatePath(), "utf8")) as UpdateState + } catch { + return null + } +} +export function writeUpdateState(s: UpdateState): void { + atomicWrite(updateStatePath(), JSON.stringify(s, null, 2)) +} + +export function atomicWrite(target: string, contents: string): void { + mkdirSync(distrotvHome(), { recursive: true, mode: 0o700 }) + const tmp = join(distrotvHome(), `.${basename(target)}.${randomBytes(6).toString("hex")}.tmp`) + writeFileSync(tmp, contents, { mode: 0o600 }) + renameSync(tmp, target) +} From bbd79cff7d1d0ce52c0ab8120ccd1eb8f9b31ba1 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 17:51:39 +0530 Subject: [PATCH 02/16] feat(cli): atomic current-symlink swap --- .../src/lib/__tests__/install-layout.test.ts | 10 ++++++++ packages/cli/src/lib/install-layout.ts | 25 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/__tests__/install-layout.test.ts b/packages/cli/src/lib/__tests__/install-layout.test.ts index 97a52910..ccc06417 100644 --- a/packages/cli/src/lib/__tests__/install-layout.test.ts +++ b/packages/cli/src/lib/__tests__/install-layout.test.ts @@ -11,6 +11,7 @@ import { readActiveVersion, readUpdateState, writeUpdateState, + swapCurrent, } from "../install-layout.js" let home: string @@ -54,4 +55,13 @@ describe("install-layout", () => { swappedAt: 5, }) }) + + it("swapCurrent atomically repoints current and is idempotent", () => { + mkdirSync(versionDir("0.2.10"), { recursive: true }) + mkdirSync(versionDir("0.2.11"), { recursive: true }) + swapCurrent("0.2.10") + expect(readActiveVersion()).toBe("0.2.10") + swapCurrent("0.2.11") + expect(readActiveVersion()).toBe("0.2.11") + }) }) diff --git a/packages/cli/src/lib/install-layout.ts b/packages/cli/src/lib/install-layout.ts index d332da6e..fc2e478e 100644 --- a/packages/cli/src/lib/install-layout.ts +++ b/packages/cli/src/lib/install-layout.ts @@ -1,5 +1,13 @@ import { randomBytes } from "node:crypto" -import { mkdirSync, readFileSync, readlinkSync, renameSync, writeFileSync } from "node:fs" +import { + mkdirSync, + readFileSync, + readlinkSync, + renameSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs" import { basename, join } from "node:path" import { homedir } from "node:os" @@ -59,3 +67,18 @@ export function atomicWrite(target: string, contents: string): void { writeFileSync(tmp, contents, { mode: 0o600 }) renameSync(tmp, target) } + +// write a temp symlink then rename over `current` — rename is atomic on POSIX. +export function swapCurrent(version: string): void { + mkdirSync(distrotvHome(), { recursive: true, mode: 0o700 }) + const tmp = join(distrotvHome(), `.current.${randomBytes(6).toString("hex")}`) + symlinkSync(versionDir(version), tmp) + renameSync(tmp, currentLink()) +} +export function tryUnlink(p: string): void { + try { + unlinkSync(p) + } catch { + /* ignore */ + } +} From c60937c22c9e0630befa6a12510a85e40bfa22bf Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 17:52:34 +0530 Subject: [PATCH 03/16] feat(cli): migrate legacy flat install to versioned layout --- .../src/lib/__tests__/install-layout.test.ts | 15 ++++++++++++++- packages/cli/src/lib/install-layout.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/__tests__/install-layout.test.ts b/packages/cli/src/lib/__tests__/install-layout.test.ts index ccc06417..c3de2abe 100644 --- a/packages/cli/src/lib/__tests__/install-layout.test.ts +++ b/packages/cli/src/lib/__tests__/install-layout.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { mkdtempSync, rmSync, mkdirSync, symlinkSync } from "node:fs" +import { mkdtempSync, rmSync, mkdirSync, symlinkSync, writeFileSync, existsSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -12,6 +12,7 @@ import { readUpdateState, writeUpdateState, swapCurrent, + migrateFlatInstall, } from "../install-layout.js" let home: string @@ -64,4 +65,16 @@ describe("install-layout", () => { swapCurrent("0.2.11") expect(readActiveVersion()).toBe("0.2.11") }) + + it("migrates a legacy flat install into versions/ + current", () => { + mkdirSync(join(home, "dist"), { recursive: true }) + writeFileSync(join(home, "dist", "index.js"), "// app") + mkdirSync(join(home, "node_modules"), { recursive: true }) + migrateFlatInstall("0.2.9") + expect(readActiveVersion()).toBe("0.2.9") + expect(existsSync(join(versionDir("0.2.9"), "dist", "index.js"))).toBe(true) + expect(existsSync(join(home, "dist"))).toBe(false) + migrateFlatInstall("0.2.9") // idempotent + expect(readActiveVersion()).toBe("0.2.9") + }) }) diff --git a/packages/cli/src/lib/install-layout.ts b/packages/cli/src/lib/install-layout.ts index fc2e478e..81daa3f0 100644 --- a/packages/cli/src/lib/install-layout.ts +++ b/packages/cli/src/lib/install-layout.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto" import { + existsSync, mkdirSync, readFileSync, readlinkSync, @@ -82,3 +83,20 @@ export function tryUnlink(p: string): void { /* ignore */ } } + +// Convert a pre-auto-update flat install (~/.distrotv/dist) into the versioned +// layout. No-op if already migrated (current exists) or no flat dist present. +// Best-effort: callers treat a throw as "stay on legacy layout", never crash. +export function migrateFlatInstall(currentVersion: string): void { + const home = distrotvHome() + const flatDist = join(home, "dist") + if (existsSync(currentLink()) || !existsSync(flatDist)) return + const vdir = versionDir(currentVersion) + mkdirSync(vdir, { recursive: true, mode: 0o700 }) + renameSync(flatDist, join(vdir, "dist")) + const flatModules = join(home, "node_modules") + if (existsSync(flatModules)) renameSync(flatModules, join(vdir, "node_modules")) + const flatPkg = join(home, "package.json") + if (existsSync(flatPkg)) renameSync(flatPkg, join(vdir, "package.json")) + swapCurrent(currentVersion) +} From 6756c514fb1240dc5fd56dea1eb4a95489a6c1d2 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 17:55:21 +0530 Subject: [PATCH 04/16] feat(cli): daemon self-check subcommand for update verification --- packages/cli/src/commands/daemon.ts | 8 +++++ .../cli/src/lib/__tests__/self-check.test.ts | 31 +++++++++++++++++++ packages/cli/src/lib/self-check.ts | 15 +++++++++ 3 files changed, 54 insertions(+) create mode 100644 packages/cli/src/lib/__tests__/self-check.test.ts create mode 100644 packages/cli/src/lib/self-check.ts diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 2a785c8e..2c8fee83 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -438,3 +438,11 @@ export const daemonCmd = new Command("daemon") process.exit(code) }) ) + .addCommand( + new Command("self-check") + .description("verify this build can boot (used by auto-update before activating)") + .action(async () => { + const { runSelfCheck } = await import("../lib/self-check.js") + process.exit(await runSelfCheck()) + }) + ) diff --git a/packages/cli/src/lib/__tests__/self-check.test.ts b/packages/cli/src/lib/__tests__/self-check.test.ts new file mode 100644 index 00000000..87f67295 --- /dev/null +++ b/packages/cli/src/lib/__tests__/self-check.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runSelfCheck } from "../self-check.js" + +// configDir() resolves via homedir() which reads HOME on Unix — override HOME +// to a temp dir so the test doesn't touch the developer's ~/.distro ledger. +let tempHome: string +let origHome: string | undefined + +beforeEach(() => { + origHome = process.env["HOME"] + tempHome = mkdtempSync(join(tmpdir(), "distro-self-check-test-")) + process.env["HOME"] = tempHome +}) + +afterEach(() => { + if (origHome !== undefined) { + process.env["HOME"] = origHome + } else { + delete process.env["HOME"] + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe("runSelfCheck", () => { + it("returns 0 when the ledger opens", async () => { + expect(await runSelfCheck()).toBe(0) + }) +}) diff --git a/packages/cli/src/lib/self-check.ts b/packages/cli/src/lib/self-check.ts new file mode 100644 index 00000000..41c6693f --- /dev/null +++ b/packages/cli/src/lib/self-check.ts @@ -0,0 +1,15 @@ +import { openLedger } from "./ledger.js" + +// Exercises the riskiest startup work — loading the bundle + the better-sqlite3 +// native dep + opening the ledger — so a staged build that can't boot is caught +// BEFORE it becomes `current`. Returns a process exit code. +export async function runSelfCheck(): Promise { + try { + const ledger = openLedger() + ledger.close?.() + return 0 + } catch (err) { + process.stderr.write(`self-check failed: ${(err as Error).message}\n`) + return 1 + } +} From e2f37ea3f341e4c177931e4ee7c89b2e57cadad1 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 17:55:30 +0530 Subject: [PATCH 05/16] feat(cli): auto-update opt-out (env + config flag) --- .../lib/__tests__/auto-update-config.test.ts | 19 +++++++++++++++++++ packages/cli/src/lib/auto-update-config.ts | 6 ++++++ packages/cli/src/lib/config.ts | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/lib/__tests__/auto-update-config.test.ts create mode 100644 packages/cli/src/lib/auto-update-config.ts diff --git a/packages/cli/src/lib/__tests__/auto-update-config.test.ts b/packages/cli/src/lib/__tests__/auto-update-config.test.ts new file mode 100644 index 00000000..acc8f590 --- /dev/null +++ b/packages/cli/src/lib/__tests__/auto-update-config.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, afterEach } from "vitest" +import { autoUpdateEnabled } from "../auto-update-config.js" + +afterEach(() => { + delete process.env.DISTRO_NO_AUTOUPDATE +}) + +describe("autoUpdateEnabled", () => { + it("defaults on", () => { + expect(autoUpdateEnabled({ autoUpdate: undefined })).toBe(true) + }) + it("env opts out", () => { + process.env.DISTRO_NO_AUTOUPDATE = "1" + expect(autoUpdateEnabled({ autoUpdate: undefined })).toBe(false) + }) + it("config flag opts out", () => { + expect(autoUpdateEnabled({ autoUpdate: false })).toBe(false) + }) +}) diff --git a/packages/cli/src/lib/auto-update-config.ts b/packages/cli/src/lib/auto-update-config.ts new file mode 100644 index 00000000..235dc895 --- /dev/null +++ b/packages/cli/src/lib/auto-update-config.ts @@ -0,0 +1,6 @@ +// On by default. DISTRO_NO_AUTOUPDATE=1 (env) or cli.autoUpdate=false (config.json, +// CLI-local — not the synced server preferences) disables it. +export function autoUpdateEnabled(cliConfig: { autoUpdate?: boolean }): boolean { + if (process.env["DISTRO_NO_AUTOUPDATE"] === "1") return false + return cliConfig.autoUpdate !== false +} diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 8ca7d84b..8355e42f 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -23,7 +23,7 @@ export interface DevdripConfig { } // secret is present for anon-registered devices; cleared post-M2 if we swap to JWT-only device: { id: string | null; secret?: string } - cli: { binPath: string } + cli: { binPath: string; autoUpdate?: boolean } preferences: DevdripPreferences } From 014b82d60d54c6cf22405dd1cdc29780c2122a5c Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:00:16 +0530 Subject: [PATCH 06/16] feat(cli): auto-update verify gate (--version + self-check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds verifyStaged() — runs the staged binary with --version and daemon self-check; both must pass before any swap. adds tar + @types/tar deps. --- packages/cli/package.json | 4 +- .../cli/src/lib/__tests__/auto-update.test.ts | 39 ++++++++++++++ packages/cli/src/lib/auto-update.ts | 38 +++++++++++++ pnpm-lock.yaml | 54 +++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/lib/__tests__/auto-update.test.ts create mode 100644 packages/cli/src/lib/auto-update.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index c4dd5419..28d4f74c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,12 +31,14 @@ "better-sqlite3": "^11.8.1", "cli-table3": "^0.6.5", "commander": "^13.0.0", - "qrcode-terminal": "^0.12.0" + "qrcode-terminal": "^0.12.0", + "tar": "^7.5.15" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^20", "@types/qrcode-terminal": "^0.12.2", + "@types/tar": "^7.0.87", "eslint": "^9.0.0", "tsup": "^8.0.0", "typescript": "^5.8.0", diff --git a/packages/cli/src/lib/__tests__/auto-update.test.ts b/packages/cli/src/lib/__tests__/auto-update.test.ts new file mode 100644 index 00000000..c303f3ee --- /dev/null +++ b/packages/cli/src/lib/__tests__/auto-update.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest" +import { verifyStaged } from "../auto-update.js" + +function fakeExec(map: Record) { + return async (_node: string, args: string[]) => { + const key = args.includes("--version") ? "version" : "self-check" + return map[key] ?? { code: 1, stdout: "" } + } +} + +describe("verifyStaged", () => { + it("passes when --version matches and self-check exits 0", async () => { + const ok = await verifyStaged("/staged", "0.2.11", { + exec: fakeExec({ + version: { code: 0, stdout: "0.2.11\n" }, + "self-check": { code: 0, stdout: "" }, + }), + }) + expect(ok).toBe(true) + }) + it("fails when --version mismatches", async () => { + const ok = await verifyStaged("/staged", "0.2.11", { + exec: fakeExec({ + version: { code: 0, stdout: "0.2.10\n" }, + "self-check": { code: 0, stdout: "" }, + }), + }) + expect(ok).toBe(false) + }) + it("fails when self-check exits non-zero", async () => { + const ok = await verifyStaged("/staged", "0.2.11", { + exec: fakeExec({ + version: { code: 0, stdout: "0.2.11\n" }, + "self-check": { code: 1, stdout: "" }, + }), + }) + expect(ok).toBe(false) + }) +}) diff --git a/packages/cli/src/lib/auto-update.ts b/packages/cli/src/lib/auto-update.ts new file mode 100644 index 00000000..d9d5e8b4 --- /dev/null +++ b/packages/cli/src/lib/auto-update.ts @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process" +import { join } from "node:path" + +// ── shared types ──────────────────────────────────────────────────────────── + +export interface ExecResult { + code: number + stdout: string +} +export type ExecFn = (node: string, args: string[]) => Promise + +const defaultExec: ExecFn = (node, args) => + new Promise((resolve) => { + const child = spawn(node, args, { stdio: ["ignore", "pipe", "ignore"] }) + let out = "" + child.stdout.on("data", (d: Buffer) => (out += d.toString())) + child.on("error", () => resolve({ code: 127, stdout: "" })) + child.on("close", (code) => resolve({ code: code ?? 1, stdout: out })) + }) + +export interface AutoUpdateDeps { + exec?: ExecFn +} + +// Gate before activation: the staged build must report the expected version AND +// pass `daemon self-check` (loads native deps + opens ledger). Both must pass. +export async function verifyStaged( + stagedDir: string, + expectedVersion: string, + deps: AutoUpdateDeps = {} +): Promise { + const exec = deps.exec ?? defaultExec + const entry = join(stagedDir, "dist", "index.js") + const v = await exec(process.execPath, [entry, "--version"]) + if (v.code !== 0 || v.stdout.trim() !== expectedVersion) return false + const sc = await exec(process.execPath, [entry, "daemon", "self-check"]) + return sc.code === 0 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0f2497a..679c5dac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: qrcode-terminal: specifier: ^0.12.0 version: 0.12.0 + tar: + specifier: ^7.5.15 + version: 7.5.15 devDependencies: '@types/better-sqlite3': specifier: ^7.6.12 @@ -272,6 +275,9 @@ importers: '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 + '@types/tar': + specifier: ^7.0.87 + version: 7.0.87 eslint: specifier: ^9.0.0 version: 9.39.4(jiti@2.6.1) @@ -1085,6 +1091,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1981,6 +1991,10 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/tar@7.0.87': + resolution: {integrity: sha512-3IxNBV8LeY5oi2ZFpvAhOtW1+mHswkzM7BuisVrwJgPv67GBO2rkLPQlEKtzfHuLdhDDczhkCZeT+RuizMay4A==} + deprecated: This is a stub types definition. tar provides its own type definitions, so you do not need this installed. + '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2493,6 +2507,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3839,6 +3857,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -4661,6 +4683,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5042,6 +5068,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -5543,6 +5573,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6248,6 +6282,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tar@7.0.87': + dependencies: + tar: 7.5.15 + '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6884,6 +6922,8 @@ snapshots: chownr@1.1.4: {} + chownr@3.0.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8384,6 +8424,10 @@ snapshots: minipass@7.1.3: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp-classic@0.5.3: {} mlly@1.8.2: @@ -9373,6 +9417,14 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + text-table@0.2.0: {} thenify-all@1.6.0: @@ -9799,6 +9851,8 @@ snapshots: xtend@4.0.2: {} + yallist@5.0.0: {} + yaml@2.8.3: {} yocto-queue@0.1.0: {} From 260eacf1a19c448d4e2552b35d4f55e068c03866 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:01:21 +0530 Subject: [PATCH 07/16] feat(cli): auto-update pipeline (stage/activate/rollback/prune/backoff) downloadAndStage fetches tarball + runs npm install for native deps in isolation. activate captures previous version before swap so rollback always has a target. rollback is a no-op when previousVersion is absent. pruneOldVersions protects active + previousVersion; removes oldest beyond keep limit. bad-version backoff persists to bad-versions.json with a 1-hour window. --- .../cli/src/lib/__tests__/auto-update.test.ts | 82 +++++++++++- packages/cli/src/lib/auto-update.ts | 123 +++++++++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/__tests__/auto-update.test.ts b/packages/cli/src/lib/__tests__/auto-update.test.ts index c303f3ee..308deeaa 100644 --- a/packages/cli/src/lib/__tests__/auto-update.test.ts +++ b/packages/cli/src/lib/__tests__/auto-update.test.ts @@ -1,5 +1,24 @@ -import { describe, it, expect } from "vitest" -import { verifyStaged } from "../auto-update.js" +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, rmSync, mkdirSync, existsSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + verifyStaged, + activate, + rollback, + pruneOldVersions, + markVersionBad, + isVersionBad, +} from "../auto-update.js" +import { + swapCurrent, + writeUpdateState, + readActiveVersion, + readUpdateState, + versionDir, +} from "../install-layout.js" + +// ── Task 6: verifyStaged ───────────────────────────────────────────────────── function fakeExec(map: Record) { return async (_node: string, args: string[]) => { @@ -37,3 +56,62 @@ describe("verifyStaged", () => { expect(ok).toBe(false) }) }) + +// ── Task 7: pipeline ───────────────────────────────────────────────────────── + +let home: string +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "dtv-")) + process.env.DISTROTV_HOME = home +}) +afterEach(() => { + delete process.env.DISTROTV_HOME + rmSync(home, { recursive: true, force: true }) +}) + +describe("pipeline", () => { + it("activate moves staged into versions/, swaps current, writes probation", () => { + mkdirSync(versionDir("0.2.10"), { recursive: true }) + swapCurrent("0.2.10") + const staged = mkdtempSync(join(tmpdir(), "stage-")) + mkdirSync(join(staged, "dist"), { recursive: true }) + activate(staged, "0.2.11", () => 7) + expect(readActiveVersion()).toBe("0.2.11") + expect(readUpdateState()).toMatchObject({ + phase: "probation", + previousVersion: "0.2.10", + newVersion: "0.2.11", + swappedAt: 7, + }) + }) + it("rollback reverts current to previous and marks the new version bad", () => { + mkdirSync(versionDir("0.2.10"), { recursive: true }) + mkdirSync(versionDir("0.2.11"), { recursive: true }) + swapCurrent("0.2.11") + writeUpdateState({ + phase: "probation", + previousVersion: "0.2.10", + newVersion: "0.2.11", + swappedAt: 1, + }) + rollback() + expect(readActiveVersion()).toBe("0.2.10") + expect(isVersionBad("0.2.11")).toBe(true) + expect(readUpdateState()?.phase).toBe("rolled-back") + }) + it("pruneOldVersions keeps the active + last N", () => { + for (const v of ["0.2.8", "0.2.9", "0.2.10"]) { + mkdirSync(versionDir(v), { recursive: true }) + } + swapCurrent("0.2.10") + pruneOldVersions(1) + expect(existsSync(versionDir("0.2.10"))).toBe(true) + expect(existsSync(versionDir("0.2.9"))).toBe(true) + expect(existsSync(versionDir("0.2.8"))).toBe(false) + }) + it("bad-version backoff records and reports", () => { + expect(isVersionBad("0.2.11")).toBe(false) + markVersionBad("0.2.11") + expect(isVersionBad("0.2.11")).toBe(true) + }) +}) diff --git a/packages/cli/src/lib/auto-update.ts b/packages/cli/src/lib/auto-update.ts index d9d5e8b4..51f68836 100644 --- a/packages/cli/src/lib/auto-update.ts +++ b/packages/cli/src/lib/auto-update.ts @@ -1,5 +1,26 @@ -import { spawn } from "node:child_process" +import { + createWriteStream, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs" import { join } from "node:path" +import { spawn } from "node:child_process" +import { pipeline } from "node:stream/promises" +import { Readable } from "node:stream" +import * as tar from "tar" +import { + atomicWrite, + badVersionsPath, + readActiveVersion, + readUpdateState, + swapCurrent, + versionDir, + versionsDir, + writeUpdateState, +} from "./install-layout.js" // ── shared types ──────────────────────────────────────────────────────────── @@ -36,3 +57,103 @@ export async function verifyStaged( const sc = await exec(process.execPath, [entry, "daemon", "self-check"]) return sc.code === 0 } + +// ── Task 7: pipeline ───────────────────────────────────────────────────────── + +export const TARBALL_URL = + "https://github.com/distroinfinity/devdrip/releases/latest/download/distrotv-cli.tar.gz" +export const KEEP_VERSIONS = 1 +export const BAD_VERSION_BACKOFF_MS = 60 * 60_000 + +// Download + extract into a staging dir under versions/. +// Returns the staged dir. Throws on any failure (caller aborts; current untouched). +export async function downloadAndStage( + url: string, + version: string, + deps: AutoUpdateDeps & { fetchImpl?: typeof fetch } = {} +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch + mkdirSync(versionsDir(), { recursive: true, mode: 0o700 }) + const staged = join(versionsDir(), `.staging-${version}-${Date.now()}`) + mkdirSync(staged, { recursive: true }) + const res = await fetchImpl(url) + if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`) + const tgz = join(staged, "cli.tar.gz") + await pipeline( + Readable.fromWeb(res.body as Parameters[0]), + createWriteStream(tgz) + ) + await tar.x({ file: tgz, cwd: staged }) + rmSync(tgz, { force: true }) + const exec = deps.exec ?? defaultExec + const npm = await exec("npm", [ + "install", + "--omit=dev", + "--no-audit", + "--no-fund", + "--prefix", + staged, + ]) + if (npm.code !== 0) throw new Error("npm install failed in staging") + return staged +} + +export function activate(stagedDir: string, version: string, now: () => number = Date.now): void { + const previous = readActiveVersion() + renameSync(stagedDir, versionDir(version)) + swapCurrent(version) + writeUpdateState({ + phase: "probation", + previousVersion: previous, + newVersion: version, + swappedAt: now(), + }) +} + +export function rollback(): void { + const st = readUpdateState() + if (!st || !st.previousVersion) return + swapCurrent(st.previousVersion) + markVersionBad(st.newVersion) + writeUpdateState({ ...st, phase: "rolled-back" }) +} + +export function pruneOldVersions(keep = KEEP_VERSIONS): void { + const active = readActiveVersion() + const st = readUpdateState() + const protectedV = new Set([active, st?.previousVersion].filter(Boolean) as string[]) + let dirs: string[] + try { + dirs = readdirSync(versionsDir()).filter((d) => !d.startsWith(".")) + } catch { + return + } + const removable = dirs.filter((d) => !protectedV.has(d)).sort() + const toRemove = removable.slice(0, Math.max(0, removable.length - keep)) + for (const d of toRemove) rmSync(versionDir(d), { recursive: true, force: true }) +} + +// ── bad-version backoff ─────────────────────────────────────────────────────── + +interface BadVersions { + [v: string]: number +} + +function readBad(): BadVersions { + try { + return JSON.parse(readFileSync(badVersionsPath(), "utf8")) as BadVersions + } catch { + return {} + } +} + +export function markVersionBad(v: string): void { + const b = readBad() + b[v] = Date.now() + atomicWrite(badVersionsPath(), JSON.stringify(b)) +} + +export function isVersionBad(v: string, now = Date.now()): boolean { + const ts = readBad()[v] + return ts !== undefined && now - ts < BAD_VERSION_BACKOFF_MS +} From 80cf7e9bc5a6ec8bd63d65e140a27b08e8377f95 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:08:05 +0530 Subject: [PATCH 08/16] fix(cli): harden auto-update activate ordering, semver prune, staging cleanup --- packages/cli/package.json | 1 - .../cli/src/lib/__tests__/auto-update.test.ts | 16 ++++- packages/cli/src/lib/auto-update.ts | 66 +++++++++++-------- pnpm-lock.yaml | 11 ---- 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 28d4f74c..8cfe5b30 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,6 @@ "@types/better-sqlite3": "^7.6.12", "@types/node": "^20", "@types/qrcode-terminal": "^0.12.2", - "@types/tar": "^7.0.87", "eslint": "^9.0.0", "tsup": "^8.0.0", "typescript": "^5.8.0", diff --git a/packages/cli/src/lib/__tests__/auto-update.test.ts b/packages/cli/src/lib/__tests__/auto-update.test.ts index 308deeaa..aa4e474c 100644 --- a/packages/cli/src/lib/__tests__/auto-update.test.ts +++ b/packages/cli/src/lib/__tests__/auto-update.test.ts @@ -23,7 +23,7 @@ import { function fakeExec(map: Record) { return async (_node: string, args: string[]) => { const key = args.includes("--version") ? "version" : "self-check" - return map[key] ?? { code: 1, stdout: "" } + return { stderr: "", ...(map[key] ?? { code: 1, stdout: "" }) } } } @@ -73,7 +73,7 @@ describe("pipeline", () => { it("activate moves staged into versions/, swaps current, writes probation", () => { mkdirSync(versionDir("0.2.10"), { recursive: true }) swapCurrent("0.2.10") - const staged = mkdtempSync(join(tmpdir(), "stage-")) + const staged = join(home, "stage-src") mkdirSync(join(staged, "dist"), { recursive: true }) activate(staged, "0.2.11", () => 7) expect(readActiveVersion()).toBe("0.2.11") @@ -99,6 +99,18 @@ describe("pipeline", () => { expect(isVersionBad("0.2.11")).toBe(true) expect(readUpdateState()?.phase).toBe("rolled-back") }) + it("rollback is a no-op when there is no previous version", () => { + mkdirSync(versionDir("0.2.11"), { recursive: true }) + swapCurrent("0.2.11") + writeUpdateState({ + phase: "probation", + previousVersion: null, + newVersion: "0.2.11", + swappedAt: 1, + }) + rollback() + expect(readActiveVersion()).toBe("0.2.11") // unchanged + }) it("pruneOldVersions keeps the active + last N", () => { for (const v of ["0.2.8", "0.2.9", "0.2.10"]) { mkdirSync(versionDir(v), { recursive: true }) diff --git a/packages/cli/src/lib/auto-update.ts b/packages/cli/src/lib/auto-update.ts index 51f68836..a2aaa0b4 100644 --- a/packages/cli/src/lib/auto-update.ts +++ b/packages/cli/src/lib/auto-update.ts @@ -21,22 +21,26 @@ import { versionsDir, writeUpdateState, } from "./install-layout.js" +import { compareSemver } from "./upgrade-check.js" // ── shared types ──────────────────────────────────────────────────────────── export interface ExecResult { code: number stdout: string + stderr: string } export type ExecFn = (node: string, args: string[]) => Promise const defaultExec: ExecFn = (node, args) => new Promise((resolve) => { - const child = spawn(node, args, { stdio: ["ignore", "pipe", "ignore"] }) + const child = spawn(node, args, { stdio: ["ignore", "pipe", "pipe"] }) let out = "" + let err = "" child.stdout.on("data", (d: Buffer) => (out += d.toString())) - child.on("error", () => resolve({ code: 127, stdout: "" })) - child.on("close", (code) => resolve({ code: code ?? 1, stdout: out })) + child.stderr.on("data", (d: Buffer) => (err += d.toString())) + child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" })) + child.on("close", (code) => resolve({ code: code ?? 1, stdout: out, stderr: err })) }) export interface AutoUpdateDeps { @@ -76,38 +80,48 @@ export async function downloadAndStage( mkdirSync(versionsDir(), { recursive: true, mode: 0o700 }) const staged = join(versionsDir(), `.staging-${version}-${Date.now()}`) mkdirSync(staged, { recursive: true }) - const res = await fetchImpl(url) - if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`) - const tgz = join(staged, "cli.tar.gz") - await pipeline( - Readable.fromWeb(res.body as Parameters[0]), - createWriteStream(tgz) - ) - await tar.x({ file: tgz, cwd: staged }) - rmSync(tgz, { force: true }) - const exec = deps.exec ?? defaultExec - const npm = await exec("npm", [ - "install", - "--omit=dev", - "--no-audit", - "--no-fund", - "--prefix", - staged, - ]) - if (npm.code !== 0) throw new Error("npm install failed in staging") - return staged + try { + const res = await fetchImpl(url) + if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`) + const tgz = join(staged, "cli.tar.gz") + await pipeline( + Readable.fromWeb(res.body as Parameters[0]), + createWriteStream(tgz) + ) + await tar.x({ file: tgz, cwd: staged }) + rmSync(tgz, { force: true }) + const exec = deps.exec ?? defaultExec + const npm = await exec("npm", [ + "install", + "--omit=dev", + "--no-audit", + "--no-fund", + "--prefix", + staged, + ]) + if (npm.code !== 0) + throw new Error("npm install failed in staging: " + npm.stderr.slice(0, 500)) + return staged + } catch (err) { + rmSync(staged, { recursive: true, force: true }) + throw err + } } export function activate(stagedDir: string, version: string, now: () => number = Date.now): void { const previous = readActiveVersion() - renameSync(stagedDir, versionDir(version)) - swapCurrent(version) + const dest = versionDir(version) + rmSync(dest, { recursive: true, force: true }) // clear any orphaned prior attempt + renameSync(stagedDir, dest) + // record rollback state BEFORE the swap: if swapCurrent throws, current still + // points at the previous (good) version AND we have the info to recover. writeUpdateState({ phase: "probation", previousVersion: previous, newVersion: version, swappedAt: now(), }) + swapCurrent(version) } export function rollback(): void { @@ -128,7 +142,7 @@ export function pruneOldVersions(keep = KEEP_VERSIONS): void { } catch { return } - const removable = dirs.filter((d) => !protectedV.has(d)).sort() + const removable = dirs.filter((d) => !protectedV.has(d)).sort(compareSemver) const toRemove = removable.slice(0, Math.max(0, removable.length - keep)) for (const d of toRemove) rmSync(versionDir(d), { recursive: true, force: true }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 679c5dac..3df6c460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,9 +275,6 @@ importers: '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 - '@types/tar': - specifier: ^7.0.87 - version: 7.0.87 eslint: specifier: ^9.0.0 version: 9.39.4(jiti@2.6.1) @@ -1991,10 +1988,6 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} - '@types/tar@7.0.87': - resolution: {integrity: sha512-3IxNBV8LeY5oi2ZFpvAhOtW1+mHswkzM7BuisVrwJgPv67GBO2rkLPQlEKtzfHuLdhDDczhkCZeT+RuizMay4A==} - deprecated: This is a stub types definition. tar provides its own type definitions, so you do not need this installed. - '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6282,10 +6275,6 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 - '@types/tar@7.0.87': - dependencies: - tar: 7.5.15 - '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 From a27067937cb63cdcee1a28c850cfa7cb393edb48 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:12:04 +0530 Subject: [PATCH 09/16] feat(cli): auto-rollback crash-looping update via respawn pre-flight --- packages/cli/src/commands/hook.ts | 10 +++- .../__tests__/preflight-rollback.test.ts | 60 +++++++++++++++++++ packages/cli/src/lib/daemon/lifecycle.ts | 39 ++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/lib/daemon/__tests__/preflight-rollback.test.ts diff --git a/packages/cli/src/commands/hook.ts b/packages/cli/src/commands/hook.ts index 30d1bce1..825d8bac 100644 --- a/packages/cli/src/commands/hook.ts +++ b/packages/cli/src/commands/hook.ts @@ -2,7 +2,11 @@ import { Command } from "commander" import { daemonSocketPath } from "@distrotv/shared/daemon-socket" import { sendHookEvent } from "../lib/daemon/hook-client.js" import { resolveTty } from "../lib/daemon/tty.js" -import { spawnDaemonDetached, tryClaimRespawn } from "../lib/daemon/lifecycle.js" +import { + spawnDaemonDetached, + tryClaimRespawn, + preflightRollbackIfStuck, +} from "../lib/daemon/lifecycle.js" import { readConfig } from "../lib/config.js" import type { WireEvent } from "../lib/daemon/protocol.js" @@ -27,6 +31,10 @@ async function maybeRevive(): Promise { const cfg = await readConfig() const bin = cfg?.cli?.binPath if (!cfg?.user?.id || !cfg?.device?.id || !bin) return + // roll back a crash-looping update before respawning so the daemon boots + // on the previous good version. swallowed — a failed rollback must not + // block the respawn. + await preflightRollbackIfStuck().catch(() => {}) spawnDaemonDetached(bin) } catch { /* swallow — hook still exits 0 */ diff --git a/packages/cli/src/lib/daemon/__tests__/preflight-rollback.test.ts b/packages/cli/src/lib/daemon/__tests__/preflight-rollback.test.ts new file mode 100644 index 00000000..5af526d8 --- /dev/null +++ b/packages/cli/src/lib/daemon/__tests__/preflight-rollback.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest" +import { shouldRollbackOnRespawn } from "../lifecycle.js" + +const base = { phase: "probation" as const, previousVersion: "0.2.10", newVersion: "0.2.11" } + +describe("shouldRollbackOnRespawn", () => { + it("rolls back when probation has had no healthy heartbeat past the threshold", () => { + const now = 200_000 + expect( + shouldRollbackOnRespawn({ + state: { ...base, swappedAt: now - 100_000 }, + lastHeartbeatAt: now - 120_000, + now, + }) + ).toBe(true) + }) + + it("does not roll back if a heartbeat appeared after the swap", () => { + const now = 200_000 + expect( + shouldRollbackOnRespawn({ + state: { ...base, swappedAt: now - 100_000 }, + lastHeartbeatAt: now - 5_000, + now, + }) + ).toBe(false) + }) + + it("does not roll back before the threshold elapses", () => { + const now = 200_000 + expect( + shouldRollbackOnRespawn({ + state: { ...base, swappedAt: now - 10_000 }, + lastHeartbeatAt: null, + now, + }) + ).toBe(false) + }) + + it("does not roll back when not in probation", () => { + expect( + shouldRollbackOnRespawn({ + state: { phase: "stable", previousVersion: "0.2.10", newVersion: "0.2.11", swappedAt: 0 }, + lastHeartbeatAt: null, + now: 999_999, + }) + ).toBe(false) + }) + + it("does not roll back when there is no previous version", () => { + const now = 200_000 + expect( + shouldRollbackOnRespawn({ + state: { ...base, previousVersion: null, swappedAt: now - 100_000 }, + lastHeartbeatAt: null, + now, + }) + ).toBe(false) + }) +}) diff --git a/packages/cli/src/lib/daemon/lifecycle.ts b/packages/cli/src/lib/daemon/lifecycle.ts index a30f8ebd..056eff6b 100644 --- a/packages/cli/src/lib/daemon/lifecycle.ts +++ b/packages/cli/src/lib/daemon/lifecycle.ts @@ -16,6 +16,8 @@ import { createConnection } from "node:net" import { join } from "node:path" import { daemonSocketPath } from "@distrotv/shared/daemon-socket" import { configDir } from "../config.js" +import type { UpdateState } from "../install-layout.js" +import { readUpdateState } from "../install-layout.js" // ── paths ─────────────────────────────────────────────────────────────── @@ -265,6 +267,43 @@ function formatField(v: unknown): string { } } +// ── probation rollback ─────────────────────────────────────────────────── + +// wait this long after a swap with no fresh heartbeat before declaring the +// new build a crash-loop and reverting. longer than the daemon's normal boot. +export const PROBATION_FAIL_AFTER_MS = 90_000 + +// pure: revert iff we're on probation, the previous version is known, the new +// build has produced NO heartbeat since the swap, and enough time has elapsed. +export function shouldRollbackOnRespawn(args: { + state: UpdateState | null + lastHeartbeatAt: number | null + now: number +}): boolean { + const { state, lastHeartbeatAt, now } = args + if (!state || state.phase !== "probation" || !state.previousVersion) return false + const healthySinceSwap = lastHeartbeatAt !== null && lastHeartbeatAt >= state.swappedAt + if (healthySinceSwap) return false + return now - state.swappedAt >= PROBATION_FAIL_AFTER_MS +} + +// called from the respawn decision. fs-only + pure decision; only when a +// rollback is actually warranted do we dynamically import the (heavier) +// auto-update module to perform it. returns true if it rolled back. +export async function preflightRollbackIfStuck(now: number = Date.now()): Promise { + const state = readUpdateState() + const hb = readHeartbeat() + if (!shouldRollbackOnRespawn({ state, lastHeartbeatAt: hb?.lastHeartbeat ?? null, now })) { + return false + } + const { rollback } = await import("../auto-update.js") + rollback() + appendLog("warn", "auto-update probation failed — rolled back", { + newVersion: state?.newVersion, + }) + return true +} + // ── socket probe ──────────────────────────────────────────────────────── export function isSocketAlive(path: string, timeoutMs = 100): Promise { From f33688cd5deffec17c229035d33744a4532c1611 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:15:28 +0530 Subject: [PATCH 10/16] feat(cli): daemon auto-update trigger + probation promotion + boot migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the auto-update pipeline into runDaemon: - migrateFlatInstall on boot (after singleton lock, before sync start) - probation → stable promotion after 60s clean uptime via unref'd timer - runUpdateCheck now downloads, verifies, activates and restarts when outdated + opted-in + version not bad + no update in flight - scheduleDaemonRestart spawns fresh process via currentEntryPath() (resolves through `current` symlink), unrefs, exits so new daemon acquires the lock --- packages/cli/src/commands/daemon.ts | 88 ++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 2c8fee83..4ff09976 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { unwatchFile, watchFile } from "node:fs" +import { rmSync, unwatchFile, watchFile } from "node:fs" import { createConnection } from "node:net" import { platform } from "node:os" import { Command } from "commander" @@ -31,10 +31,46 @@ import { createOrchestrator } from "../lib/daemon/orchestrator.js" import { startDaemonServer } from "../lib/daemon/server.js" import { createSyncLoop } from "../lib/daemon/sync.js" import { syncPreferencesOnce } from "../lib/daemon/prefs-sync.js" +import { + downloadAndStage, + verifyStaged, + activate, + pruneOldVersions, + isVersionBad, + markVersionBad, + TARBALL_URL, +} from "../lib/auto-update.js" +import { autoUpdateEnabled } from "../lib/auto-update-config.js" +import { + migrateFlatInstall, + readUpdateState, + writeUpdateState, + currentEntryPath, +} from "../lib/install-layout.js" const HEARTBEAT_INTERVAL_MS = 10_000 const START_POLL_DEADLINE_MS = 2_000 +let updateInFlight = false + +function scheduleDaemonRestart(log: { + warn: (msg: string, fields?: Record) => void +}): void { + setTimeout(() => { + try { + const child = spawn(process.execPath, [currentEntryPath(), "daemon", "run"], { + detached: true, + stdio: "ignore", + env: process.env, + }) + child.unref() + } catch (e) { + log.warn("restart spawn failed", { error: (e as Error).message }) + } + process.exit(0) + }, 1500) +} + export async function runStart(): Promise { const cfg = await readConfig() if (!cfg || !cfg.user?.id || !cfg.device?.id) { @@ -172,6 +208,13 @@ export async function runDaemon(): Promise { return 1 } + // A. migrate flat install → versioned layout (no-op if already migrated). + try { + migrateFlatInstall(cliVersion()) + } catch (e) { + appendLog("warn", "layout migrate skipped", { error: (e as Error).message }) + } + const socketPath = resolveSocketPath() unlinkSocketIfExists(socketPath) @@ -192,6 +235,22 @@ export async function runDaemon(): Promise { const syncLoop = createSyncLoop({ ledger, log }) syncLoop.start() + // A (continued). probation promotion: if the last update is on probation, + // promote to stable after 60s of clean boot (no crash = healthy). + const upState = readUpdateState() + if (upState?.phase === "probation") { + const promo = setTimeout(() => { + try { + writeUpdateState({ ...upState, phase: "stable" }) + pruneOldVersions() + log.info("update promoted to stable", { version: upState.newVersion }) + } catch (e) { + log.warn("probation promotion failed", { error: (e as Error).message }) + } + }, 60_000) + promo.unref?.() + } + // Eager prefs fetch on boot so dashboard changes apply immediately — // without this we'd wait up to 30 min for the first sync tick. Fire and // forget; the ingest loop will retry within 30 min if this one fails. @@ -210,11 +269,36 @@ export async function runDaemon(): Promise { // every 15 min so a freshly-published release surfaces fast — cheap at our // scale, and maybeCheck's 10-min cache is shorter than this so each poll // actually re-fetches (it also runs once here on daemon boot). + // B. auto-update trigger: when outdated + enabled + version not bad + no + // update already in flight, download, verify, activate, and restart. const runUpdateCheck = (): void => { maybeCheck(cliVersion()) - .then((r) => { + .then(async (r) => { setPendingUpdate(r?.outdated ? r.latest : null) log.debug("update check", { outdated: r?.outdated ?? false, latest: r?.latest }) + if (!r?.outdated) return + const cfg = await readConfig() + if (!autoUpdateEnabled(cfg?.cli ?? {}) || isVersionBad(r.latest) || updateInFlight) return + updateInFlight = true + try { + log.info("auto-update: downloading", { version: r.latest }) + const staged = await downloadAndStage(TARBALL_URL, r.latest) + if (!(await verifyStaged(staged, r.latest))) { + rmSync(staged, { recursive: true, force: true }) + markVersionBad(r.latest) + log.warn("auto-update: staged build failed verification — skipped", { + version: r.latest, + }) + return + } + activate(staged, r.latest) + log.info("auto-update: activated, restarting daemon", { version: r.latest }) + scheduleDaemonRestart(log) + } catch (e) { + log.warn("auto-update failed", { error: (e as Error).message }) + } finally { + updateInFlight = false + } }) .catch((err) => log.debug("update check failed", { error: (err as Error).message })) } From 237cb2d3f5e478e6f99d236b7e00d7df882583a3 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:19:33 +0530 Subject: [PATCH 11/16] feat(cli): point Claude hooks at the stable current path (survives version swaps) --- packages/cli/src/commands/init.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 627352e6..cbd02140 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,7 +1,7 @@ import { mkdir } from "node:fs/promises" import { spawn } from "node:child_process" import { homedir } from "node:os" -import { lstatSync, mkdirSync, realpathSync, statSync, symlinkSync, unlinkSync } from "node:fs" +import { lstatSync, mkdirSync, symlinkSync, unlinkSync } from "node:fs" import { join } from "node:path" import { Command } from "commander" import { intro, outro, log, note, spinner } from "@clack/prompts" @@ -17,6 +17,7 @@ import { type MeResponse, } from "../lib/api-client.js" import { readConfig, writeConfig } from "../lib/config.js" +import { currentEntryPath } from "../lib/install-layout.js" import { defaultDevdripPreferences } from "@distrotv/shared" import { readSettings, @@ -66,20 +67,12 @@ function tryUnlink(p: string): void { } // returns a stable user-scoped path (~/.distro/bin/distro) that symlinks to -// the currently running binary. writing this into settings.json hooks means a -// worktree deletion can be recovered by re-running `distro init` from any -// working build — the symlink retargets, the hook entries never change. +// the stable current-version entry (~/.distrotv/current/dist/index.js). writing +// this into settings.json hooks means the hook command survives version swaps — +// `current` is retargeted by the auto-update swap, so hooks always invoke the +// active version without needing to re-run `distro init`. function resolveBinPath(): string { - const arg = process.argv[1] - if (!arg) return "" - - let source: string - try { - if (!statSync(arg).isFile()) return arg - source = realpathSync(arg) - } catch { - return arg - } + const source = currentEntryPath() const linkPath = distroBinLinkPath() const dtvPath = dtvBinLinkPath() From 351c071c2eb28798d99266c42e5514a60f800bdc Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:19:40 +0530 Subject: [PATCH 12/16] feat(cli): install.sh lays out versioned dirs + current symlink --- frontend/public/install.sh | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/public/install.sh b/frontend/public/install.sh index 1f754582..e40a9862 100755 --- a/frontend/public/install.sh +++ b/frontend/public/install.sh @@ -22,14 +22,20 @@ if [ "$NODE_MAJOR" -lt 20 ]; then exit 1 fi -# 2. download and extract +# 2. download and extract into a temp staging dir mkdir -p "$INSTALL_DIR" "$BIN_DIR" echo "→ downloading distro tv cli..." TMP=$(mktemp -d) +EXTRACTED="$TMP/extracted" +mkdir -p "$EXTRACTED" curl -fsSL "$TARBALL_URL" -o "$TMP/distrotv-cli.tar.gz" -tar -xzf "$TMP/distrotv-cli.tar.gz" -C "$INSTALL_DIR" +tar -xzf "$TMP/distrotv-cli.tar.gz" -C "$EXTRACTED" -# 2a. install the native dep (better-sqlite3). The tarball ships a stripped +# determine the version from the extracted package.json; fall back to "latest" +VERSION=$(node -p "require('$EXTRACTED/package.json').version" 2>/dev/null || echo "latest") + +# 2a. install into versioned dir so the current symlink can be swapped on upgrade +# without touching the active installation. the tarball ships a stripped # runtime package.json listing only modules that can't be bundled — pure-JS # deps are inlined into dist/ by tsup. npm fetches the matching per-platform # prebuild so users don't need a C++ toolchain. @@ -37,18 +43,22 @@ if ! command -v npm >/dev/null 2>&1; then echo "✗ npm not found. install node 20+ (ships with npm) and re-run." >&2 exit 1 fi +DEST="$INSTALL_DIR/versions/$VERSION" +mkdir -p "$DEST" +cp -R "$EXTRACTED/." "$DEST/" echo "→ installing native dependencies..." -(cd "$INSTALL_DIR" && npm install --omit=dev --no-audit --no-fund --silent) +(cd "$DEST" && npm install --omit=dev --no-audit --no-fund --silent) + +# point current at the just-installed version +ln -sfn "versions/$VERSION" "$INSTALL_DIR/current" # 3. wrappers — `distro` is the primary command; `dtv` is a collision-proof # alias. the Python `distro` package ships its own `distro` CLI (usage: # `distro [-h] [--json] [--root-dir ROOT_DIR]`) that can shadow ours on PATH # (e.g. when /opt/homebrew/bin wins), so `dtv` always resolves to us. +# shims target the stable current/ path so they survive version swaps. for name in distro dtv; do - cat > "$BIN_DIR/$name" < "$BIN_DIR/$name" chmod +x "$BIN_DIR/$name" done From 79370b898c49d95b69c21a62ac35283f8e156323 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:19:45 +0530 Subject: [PATCH 13/16] feat(cli): distro upgrade applies the update via the pipeline --- packages/cli/src/commands/upgrade.ts | 51 +++++++++++++++++++--------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index d0ce56af..c57c614c 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -2,6 +2,7 @@ import { createRequire } from "node:module" import { Command } from "commander" import { detectColor, dim, green, yellow } from "../lib/ansi.js" import { compareSemver, maybeCheck } from "../lib/upgrade-check.js" +import { downloadAndStage, verifyStaged, activate, TARBALL_URL } from "../lib/auto-update.js" const require = createRequire(import.meta.url) @@ -12,28 +13,15 @@ function currentVersion(): string { return version } -function printResult(current: string, latest: string, cached: boolean): void { +function printStatus(current: string, latest: string, cached: boolean): void { const color = detectColor() - const cmp = compareSemver(current, latest) const cacheTag = cached ? dim(" (cached — checked within 7d)", color) : "" process.stdout.write(`current: ${current}\n`) process.stdout.write(`latest: ${latest}${cacheTag}\n`) - if (cmp < 0) { - process.stdout.write( - `\n${yellow("→ curl -fsSL https://get.distrotv.xyz/install.sh | sh", color)}\n` - ) - } else if (cmp === 0) { - process.stdout.write(`\n${green("you're up to date", color)}\n`) - } else { - // local version ahead of the latest release — common during dev builds - process.stdout.write( - `\n${dim("(local build ahead of latest release — nothing to do)", color)}\n` - ) - } } export const upgradeCmd = new Command("upgrade") - .description("check GitHub Releases for updates") + .description("check for updates and apply them if available") .option("--force", "bypass the 7-day cache and fetch the latest release now") .action(async (opts: { force?: boolean }) => { try { @@ -45,7 +33,38 @@ export const upgradeCmd = new Command("upgrade") console.log(`current: ${current}\nlatest: unknown (network timeout)`) process.exit(0) } - printResult(current, result.latest, result.cached) + printStatus(current, result.latest, result.cached) + const color = detectColor() + const cmp = compareSemver(current, result.latest) + if (cmp < 0) { + // outdated — download and apply the update + process.stdout.write("downloading update…\n") + try { + const staged = await downloadAndStage(TARBALL_URL, result.latest) + if (!(await verifyStaged(staged, result.latest))) { + console.error("update failed verification — not applied") + process.exit(1) + } + activate(staged, result.latest) + process.stdout.write( + `\n${green(`updated to ${result.latest}`, color)} — restart the daemon to run it: distro daemon stop && distro daemon start\n` + ) + } catch (applyErr) { + // download/apply failed — fall back to the manual curl hint + console.error(`auto-update failed: ${(applyErr as Error).message}`) + process.stdout.write( + `\n${yellow("→ curl -fsSL https://get.distrotv.xyz/install.sh | sh", color)}\n` + ) + process.exit(1) + } + } else if (cmp === 0) { + process.stdout.write(`\n${green("you're up to date", color)}\n`) + } else { + // local version ahead of the latest release — common during dev builds + process.stdout.write( + `\n${dim("(local build ahead of latest release — nothing to do)", color)}\n` + ) + } process.exit(0) } catch (err) { console.error(`upgrade check failed: ${(err as Error).message}`) From cd571484d9019486ec5d35a0f710151155d15bea Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:24:24 +0530 Subject: [PATCH 14/16] test(cli): e2e auto-update happy-path + verify-reject + rollback + opt-out --- .../integration/auto-update-e2e.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/cli/src/__tests__/integration/auto-update-e2e.test.ts diff --git a/packages/cli/src/__tests__/integration/auto-update-e2e.test.ts b/packages/cli/src/__tests__/integration/auto-update-e2e.test.ts new file mode 100644 index 00000000..0e03471b --- /dev/null +++ b/packages/cli/src/__tests__/integration/auto-update-e2e.test.ts @@ -0,0 +1,195 @@ +/** + * e2e: auto-update pipeline — real tarball, real node subprocess verification. + * + * Each test gets an isolated DISTROTV_HOME (mkdtemp). A fixture tarball is built + * at runtime via the `tar` package. verifyStaged uses the real defaultExec (no + * exec stub) so it actually spawns `node /dist/index.js`. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import * as tar from "tar" +import { + downloadAndStage, + verifyStaged, + activate, + rollback, + isVersionBad, +} from "../../lib/auto-update.js" +import { + swapCurrent, + writeUpdateState, + readActiveVersion, + readUpdateState, + versionDir, +} from "../../lib/install-layout.js" +import { shouldRollbackOnRespawn } from "../../lib/daemon/lifecycle.js" +import { autoUpdateEnabled } from "../../lib/auto-update-config.js" + +// ── fixture helpers ─────────────────────────────────────────────────────────── + +/** Build a fixture tarball from a pre-populated directory. Returns tgzPath. */ +async function buildFixtureTarball(fixtureDir: string, tgzPath: string): Promise { + await tar.c({ gzip: true, file: tgzPath, cwd: fixtureDir }, ["."]) +} + +/** Minimal "good" fixture: --version prints version, daemon self-check exits 0. */ +function writeGoodFixture(dir: string, version: string): void { + mkdirSync(join(dir, "dist"), { recursive: true }) + writeFileSync( + join(dir, "dist", "index.js"), + [ + "const a = process.argv.slice(2)", + `if (a[0] === "--version") { console.log("${version}"); process.exit(0) }`, + `if (a[0] === "daemon" && a[1] === "self-check") { process.exit(0) }`, + "process.exit(0)", + ].join("\n") + ) + writeFileSync(join(dir, "package.json"), JSON.stringify({ version })) +} + +/** Broken fixture: daemon self-check exits 1. */ +function writeBrokenFixture(dir: string, version: string): void { + mkdirSync(join(dir, "dist"), { recursive: true }) + writeFileSync( + join(dir, "dist", "index.js"), + [ + "const a = process.argv.slice(2)", + `if (a[0] === "--version") { console.log("${version}"); process.exit(0) }`, + `if (a[0] === "daemon" && a[1] === "self-check") { process.exit(1) }`, + "process.exit(0)", + ].join("\n") + ) + writeFileSync(join(dir, "package.json"), JSON.stringify({ version })) +} + +/** Build a fetchImpl stub that streams the contents of a local tarball. */ +function makeFetchStub(tgzPath: string): typeof fetch { + return (async () => { + const buf = readFileSync(tgzPath) + const blob = new Blob([buf]) + return new Response(blob.stream() as unknown as ReadableStream, { + status: 200, + }) + }) as unknown as typeof fetch +} + +/** exec stub for the npm-install step — always succeeds instantly. */ +const noopExec = async () => ({ code: 0, stdout: "", stderr: "" }) + +// ── per-test isolation ──────────────────────────────────────────────────────── + +let home: string +let tmp: string // scratch space for fixture files + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "dtv-e2e-home-")) + tmp = mkdtempSync(join(tmpdir(), "dtv-e2e-tmp-")) + process.env.DISTROTV_HOME = home +}) + +afterEach(() => { + delete process.env.DISTROTV_HOME + delete process.env.DISTRO_NO_AUTOUPDATE + rmSync(home, { recursive: true, force: true }) + rmSync(tmp, { recursive: true, force: true }) +}) + +// ── lay down an "old" active version ───────────────────────────────────────── + +function layOldVersion(v = "0.2.9"): void { + const dir = versionDir(v) + mkdirSync(join(dir, "dist"), { recursive: true }) + writeFileSync(join(dir, "dist", "index.js"), `// old ${v}`) + swapCurrent(v) +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("auto-update e2e", () => { + it("test 1 — happy path: real node verifies staged build, activate commits it", async () => { + layOldVersion("0.2.9") + + // build fixture tarball + const fixtureDir = join(tmp, "fixture-good") + writeGoodFixture(fixtureDir, "9.9.9") + const tgzPath = join(tmp, "cli-good.tar.gz") + await buildFixtureTarball(fixtureDir, tgzPath) + + // download + stage (fetch stub serves local tarball; npm stub is a no-op) + const staged = await downloadAndStage("http://fixture/cli.tar.gz", "9.9.9", { + fetchImpl: makeFetchStub(tgzPath), + exec: noopExec, // only used for npm install step + }) + + // verifyStaged uses NO exec override → real `node` spawned against staged build + const ok = await verifyStaged(staged, "9.9.9") + expect(ok).toBe(true) // real node ran /dist/index.js --version and daemon self-check + + activate(staged, "9.9.9") + expect(readActiveVersion()).toBe("9.9.9") + const state = readUpdateState() + expect(state?.phase).toBe("probation") + expect(state?.previousVersion).toBe("0.2.9") + }) + + it("test 2 — broken build: verify returns false, old version stays active", async () => { + layOldVersion("0.2.9") + + // build broken fixture tarball + const fixtureDir = join(tmp, "fixture-broken") + writeBrokenFixture(fixtureDir, "9.9.9") + const tgzPath = join(tmp, "cli-broken.tar.gz") + await buildFixtureTarball(fixtureDir, tgzPath) + + const staged = await downloadAndStage("http://fixture/cli.tar.gz", "9.9.9", { + fetchImpl: makeFetchStub(tgzPath), + exec: noopExec, + }) + + // real node spawned: self-check exits 1 → verify must return false + const ok = await verifyStaged(staged, "9.9.9") + expect(ok).toBe(false) + + // we intentionally do NOT call activate + expect(readActiveVersion()).toBe("0.2.9") + + // clean up the rejected staging dir (normally the caller does this on verify failure) + rmSync(staged, { recursive: true, force: true }) + }) + + it("test 3 — rollback reverts current and marks new version bad", () => { + // arrange: 0.2.9 (previous) and 9.9.9 (probation) both present on disk + layOldVersion("0.2.9") + mkdirSync(versionDir("9.9.9"), { recursive: true }) + swapCurrent("9.9.9") + writeUpdateState({ + phase: "probation", + previousVersion: "0.2.9", + newVersion: "9.9.9", + swappedAt: 1000, + }) + + // confirm the rollback trigger fires: probation with no heartbeat after >90s + const shouldRollback = shouldRollbackOnRespawn({ + state: readUpdateState(), + lastHeartbeatAt: null, + now: 1000 + 100_000, + }) + expect(shouldRollback).toBe(true) + + rollback() + expect(readActiveVersion()).toBe("0.2.9") + expect(isVersionBad("9.9.9")).toBe(true) + }) + + it("test 4 — opt-out: env var and config flag both disable auto-update", () => { + process.env.DISTRO_NO_AUTOUPDATE = "1" + expect(autoUpdateEnabled({})).toBe(false) + + delete process.env.DISTRO_NO_AUTOUPDATE + expect(autoUpdateEnabled({ autoUpdate: false })).toBe(false) + expect(autoUpdateEnabled({})).toBe(true) + }) +}) From 647d7b7235b9770cdb88e1295b98b0a8c8ada1bc Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:27:24 +0530 Subject: [PATCH 15/16] =?UTF-8?q?docs:=20CLI=20auto-update=20=E2=80=94=20v?= =?UTF-8?q?ersioned=20layout,=20pipeline,=20rollback,=20opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- gitbook-docs/cli/current-state.md | 12 +++++++++++- gitbook-docs/cli/daemon-and-hooks.md | 10 ++++++++++ gitbook-docs/cli/releases.md | 12 ++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e8dd48b7..51c1a8a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Distro TV is an ambient **channel surface** that runs in the developer's termina - local ledger is ground truth — backend can be down - no grace period before showing slots — surface as soon as Claude takes over (`GRACE_PERIOD_MS = 0`). Fast tool calls are gated by the slot vanish timer + frequency caps, not by a pre-show delay. - **lead with _channels_ as the surface noun, never with "news + markets"** — Distro TV is a channel platform; NEWS and MARKETS are the two launch channels; future verticals slot into the same surface. Marketing, docs, and product copy must reflect this. The two-tangent "news AND market data" framing is what we explicitly pivoted away from in M8. -- **CLI distribution = `curl -fsSL https://get.distrotv.xyz/install.sh | sh` + GitHub Releases, never npm publish.** install.sh is served from **GitHub Pages at `get.distrotv.xyz`**, NOT Vercel — Vercel's edge firewall JS-challenges `curl` (`x-vercel-mitigated: challenge`), which `curl | sh` can't solve. Source file is `frontend/public/install.sh` (single source of truth), deployed by `.github/workflows/deploy-install.yml`; it pulls the latest tarball from `releases/latest/download/distrotv-cli.tar.gz`. Releases are triggered by pushing a `cli-v*` git tag. See `gitbook-docs/cli/releases.md`. +- **CLI distribution = `curl -fsSL https://get.distrotv.xyz/install.sh | sh` + GitHub Releases, never npm publish.** install.sh is served from **GitHub Pages at `get.distrotv.xyz`**, NOT Vercel — Vercel's edge firewall JS-challenges `curl` (`x-vercel-mitigated: challenge`), which `curl | sh` can't solve. Source file is `frontend/public/install.sh` (single source of truth), deployed by `.github/workflows/deploy-install.yml`; it pulls the latest tarball from `releases/latest/download/distrotv-cli.tar.gz`. Releases are triggered by pushing a `cli-v*` git tag. install.sh lays out a **versioned install** — each release lands in `~/.distrotv/versions//`; `~/.distrotv/current` symlink points at the active version; shims and Claude hook entries resolve through `current/dist/index.js`. the CLI **auto-updates** on the daemon's ~15-min check (opt-out: `DISTRO_NO_AUTOUPDATE=1` or `cli.autoUpdate: false`). See `gitbook-docs/cli/releases.md`. ## Dev Rules diff --git a/gitbook-docs/cli/current-state.md b/gitbook-docs/cli/current-state.md index 6efb4a6a..0e897d0b 100644 --- a/gitbook-docs/cli/current-state.md +++ b/gitbook-docs/cli/current-state.md @@ -119,7 +119,17 @@ Fetches one real slot from `/me/content/next`, renders with a `[DEMO]` badge. Va ## `distro upgrade` -Checks the GitHub Releases API for a newer `distrotv-cli.tar.gz`. No auto-install; prints the download URL for the user's package manager / installer. The daemon also runs this check on boot and every 15 min to drive the status-line "update available" nudge, with a short 10-min fetch cache at `~/.distro/upgrade-check.json` (was 7 days — shortened so a fresh release surfaces within ~15 min at our small scale). `distro upgrade --force` bypasses the cache. +Downloads, verifies, and activates the latest `distrotv-cli.tar.gz` from GitHub Releases. The pipeline: download tarball → extract to a staging dir → `npm install` native deps → verify (`node /dist/index.js --version` must match the release tag AND `daemon self-check` must exit 0) → atomically move into `~/.distrotv/versions//` and repoint the `~/.distrotv/current` symlink → restart the daemon through `current`. `distro upgrade --force` bypasses the 10-min fetch cache. + +## Auto-update + +The daemon runs the update check on boot and every ~15 min. When a newer release is found it runs the same download → verify → activate pipeline automatically, then restarts itself. + +**Versioned install layout:** `install.sh` extracts each release into `~/.distrotv/versions//` and keeps `~/.distrotv/current` pointing at the active version. Shims (`~/.local/bin/distro`, `dtv`) and Claude hook entries always resolve through the stable `~/.distrotv/current/dist/index.js` path, so a version swap is invisible to them. A legacy flat `~/.distrotv/dist` install is self-migrated into the versioned layout on first boot (`migrateFlatInstall`). + +**Safety / rollback:** activation writes rollback state (`update-state.json`, phase `probation`, `previousVersion`) before the symlink swap. After restart the new daemon runs on probation; once healthy for ~60s it promotes to `stable` and prunes old versions (keeps last 1, semver-aware). If the new build crash-loops (no heartbeat past a ~90s threshold), the Claude hook respawn path performs an fs-only rollback — reverts `current` to the previous version and marks the bad version for a 1-hour backoff — without ever blocking Claude Code (hooks always exit 0). See [daemon-and-hooks.md](./daemon-and-hooks.md#auto-update--rollback). + +**Opt-out:** auto-update is on by default. Disable with `DISTRO_NO_AUTOUPDATE=1` (env) or `cli.autoUpdate: false` in `~/.distro/config.json`. When disabled, the daemon falls back to the passive status-line nudge. There is no server-side gate — to halt a bad release, yank or replace the GitHub release asset. ## What is missing diff --git a/gitbook-docs/cli/daemon-and-hooks.md b/gitbook-docs/cli/daemon-and-hooks.md index f53a2aa6..e55cdf9a 100644 --- a/gitbook-docs/cli/daemon-and-hooks.md +++ b/gitbook-docs/cli/daemon-and-hooks.md @@ -210,6 +210,16 @@ Ad-only side effects (campaign cap, hourly/daily fatigue caps, ad ledger writes, The save keybind `b` writes to `reading_pending` (local SQLite) and syncs to `/me/reading`. +## Auto-update + rollback + +The daemon runs the GitHub-Releases update check on boot and every ~15 min (same tick as the existing nudge). When a newer release is found it runs the full pipeline automatically: download tarball → extract → `npm install` native deps in a staging dir → verify (`node /dist/index.js --version` matches the tag, `daemon self-check` exits 0) → atomic `rename` into `~/.distrotv/versions//` → repoint `~/.distrotv/current` symlink → spawn a fresh daemon through `current` and exit. + +**Probation / promotion:** before the symlink swap, the daemon writes `update-state.json` (phase `probation`, `previousVersion`). After restart the new daemon runs on probation; once it has been heartbeating cleanly for ~60s it promotes itself to `stable` and prunes old versions (keeps 1, semver-aware). + +**Crash-loop rollback:** if the new daemon never produces a heartbeat within ~90s of the swap, the next time the Claude hook fires and fails to reach the daemon (`sendHookEvent` returns `"unreachable"`), the hook respawn path runs `shouldRollbackOnRespawn` / `preflightRollbackIfStuck` (`lib/daemon/lifecycle.ts`, wired in `commands/hook.ts`). This is a pure fs check — it reads `update-state.json`, reverts `current` to `previousVersion`, marks the bad version with a 1h backoff, then spawns the old daemon. The hook always exits 0. See the [activity gate](#activity-gate-per-tty) section for where respawning fits in the hook fast path. + +**Opt-out:** `DISTRO_NO_AUTOUPDATE=1` or `cli.autoUpdate: false` disables the auto-install step; the daemon falls back to the passive status-line nudge. No server-side gate — to halt a bad release, yank or replace the GitHub release asset. + ## What's next - **`distro doctor`** — checks heartbeat age, hooks, backend reachability. diff --git a/gitbook-docs/cli/releases.md b/gitbook-docs/cli/releases.md index d3a03562..6fbdecf0 100644 --- a/gitbook-docs/cli/releases.md +++ b/gitbook-docs/cli/releases.md @@ -107,6 +107,18 @@ wild keep working when the Vercel challenge isn't firing. 3. Watch the Actions tab; the Release CLI workflow should produce the release. 4. Verify `https://github.com/distroinfinity/devdrip/releases/latest/download/distrotv-cli.tar.gz` returns the tarball. +## cli-v0.2.10 (2026-05-27) + +**Added:** CLI auto-update — the daemon now applies updates automatically on the ~15-min update-check tick instead of only nudging. + +- **versioned install layout:** each release lands in `~/.distrotv/versions//`; `~/.distrotv/current` symlink points at the active version. Shims and Claude hook entries always resolve through `current/dist/index.js`, so a version swap is invisible to them. Legacy flat `~/.distrotv/dist` installs are self-migrated on first boot. +- **pipeline:** download tarball → extract → `npm install` native deps in staging → verify (`--version` + `daemon self-check`) → atomic rename into `versions/` → repoint `current` → daemon restart. +- **rollback:** probation phase written before the symlink swap; promotes to stable after ~60s healthy. If the new build crash-loops, the Claude hook respawn path auto-reverts `current` to the previous version (1h backoff on the bad version) with an fs-only check — hooks always exit 0. +- **opt-out:** `DISTRO_NO_AUTOUPDATE=1` or `cli.autoUpdate: false` in config falls back to the passive nudge. +- **`distro upgrade`** now actually installs (same pipeline) instead of printing a curl command. + +**Rollout caveat:** the auto-update mechanism only reaches existing users once they are on `cli-v0.2.10`+. Users on earlier releases must still take the first hop manually — either via the update nudge (`distro upgrade`) or the install one-liner (`curl -fsSL https://get.distrotv.xyz/install.sh | sh`). Subsequent updates will be fully automatic. + ## cli-v0.2.9 (2026-05-25) **Fixed:** the `init`/`doctor` health check no longer reports false **GitHub sign-in** / **backend reachable** failures. The two network probes ran on a 500ms abort budget while every real request uses 10s; warm-prod TLS/DNS on a freshly-spawned process routinely blew past 500ms, so probes timed out even though the backend was reachable. Probes now use a 2.5s budget and retry once at 5s on transient failures (timeout / network / 5xx), skipping the retry on definitive answers (4xx, not-signed-in). From 9d534a66b03a6a4d0d1ad43c710e685116efb822 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Wed, 27 May 2026 18:39:26 +0530 Subject: [PATCH 16/16] fix(cli): manual upgrade clears probation + cleans staging on verify fail --- packages/cli/src/commands/upgrade.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index c57c614c..d6a173b4 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -1,8 +1,10 @@ import { createRequire } from "node:module" +import { rmSync } from "node:fs" import { Command } from "commander" import { detectColor, dim, green, yellow } from "../lib/ansi.js" import { compareSemver, maybeCheck } from "../lib/upgrade-check.js" import { downloadAndStage, verifyStaged, activate, TARBALL_URL } from "../lib/auto-update.js" +import { readUpdateState, writeUpdateState } from "../lib/install-layout.js" const require = createRequire(import.meta.url) @@ -42,10 +44,13 @@ export const upgradeCmd = new Command("upgrade") try { const staged = await downloadAndStage(TARBALL_URL, result.latest) if (!(await verifyStaged(staged, result.latest))) { + rmSync(staged, { recursive: true, force: true }) console.error("update failed verification — not applied") process.exit(1) } activate(staged, result.latest) + const st = readUpdateState() + if (st) writeUpdateState({ ...st, phase: "stable" }) process.stdout.write( `\n${green(`updated to ${result.latest}`, color)} — restart the daemon to run it: distro daemon stop && distro daemon start\n` )