Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ee97207
feat(cli): versioned install-layout path helpers + update-state
distroinfinity May 27, 2026
bbd79cf
feat(cli): atomic current-symlink swap
distroinfinity May 27, 2026
c60937c
feat(cli): migrate legacy flat install to versioned layout
distroinfinity May 27, 2026
6756c51
feat(cli): daemon self-check subcommand for update verification
distroinfinity May 27, 2026
e2f37ea
feat(cli): auto-update opt-out (env + config flag)
distroinfinity May 27, 2026
014b82d
feat(cli): auto-update verify gate (--version + self-check)
distroinfinity May 27, 2026
260eacf
feat(cli): auto-update pipeline (stage/activate/rollback/prune/backoff)
distroinfinity May 27, 2026
80cf7e9
fix(cli): harden auto-update activate ordering, semver prune, staging…
distroinfinity May 27, 2026
a270679
feat(cli): auto-rollback crash-looping update via respawn pre-flight
distroinfinity May 27, 2026
f33688c
feat(cli): daemon auto-update trigger + probation promotion + boot mi…
distroinfinity May 27, 2026
237cb2d
feat(cli): point Claude hooks at the stable current path (survives ve…
distroinfinity May 27, 2026
351c071
feat(cli): install.sh lays out versioned dirs + current symlink
distroinfinity May 27, 2026
79370b8
feat(cli): distro upgrade applies the update via the pipeline
distroinfinity May 27, 2026
cd57148
test(cli): e2e auto-update happy-path + verify-reject + rollback + op…
distroinfinity May 27, 2026
647d7b7
docs: CLI auto-update — versioned layout, pipeline, rollback, opt-out
distroinfinity May 27, 2026
9d534a6
fix(cli): manual upgrade clears probation + cleans staging on verify …
distroinfinity May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<v>/`; `~/.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

Expand Down
26 changes: 18 additions & 8 deletions frontend/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,43 @@ 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.
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" <<EOF
#!/bin/sh
exec node "$INSTALL_DIR/dist/index.js" "\$@"
EOF
printf '#!/bin/sh\nexec node "%s/current/dist/index.js" "$@"\n' "$INSTALL_DIR" > "$BIN_DIR/$name"
chmod +x "$BIN_DIR/$name"
done

Expand Down
12 changes: 11 additions & 1 deletion gitbook-docs/cli/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <staged>/dist/index.js --version` must match the release tag AND `daemon self-check` must exit 0) → atomically move into `~/.distrotv/versions/<v>/` 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/<v>/` 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

Expand Down
10 changes: 10 additions & 0 deletions gitbook-docs/cli/daemon-and-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <staged>/dist/index.js --version` matches the tag, `daemon self-check` exits 0) → atomic `rename` into `~/.distrotv/versions/<v>/` → 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.
Expand Down
12 changes: 12 additions & 0 deletions gitbook-docs/cli/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<v>/`; `~/.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/<v>` → 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).
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"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",
Expand Down
195 changes: 195 additions & 0 deletions packages/cli/src/__tests__/integration/auto-update-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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 <staged>/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<void> {
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 <staged>/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)
})
})
Loading
Loading