Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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. 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`.
- **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 tick: the **version signal comes from our API** (`GET /cli/version-check`, gated by `LATEST_CLI_VERSION` env var on Railway — unset = no update advertised); tarballs still live on GitHub Releases. set `LATEST_CLI_VERSION=<version>` after each release to roll out; unset to halt. opt-out: `DISTRO_NO_AUTOUPDATE=1` or `cli.autoUpdate: false`. See `gitbook-docs/cli/releases.md`.

## Dev Rules

Expand Down
42 changes: 42 additions & 0 deletions gitbook-docs/backend/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,48 @@ Response shape:
}
```

## `GET /cli/version-check`

Purpose:
server-driven update check for the CLI. Called by the daemon on boot and each tick, and by `distro upgrade` / `distro status`.

Auth: none (public)

Query params:

- `current` — semver string of the installed CLI (e.g. `0.2.10`)

Behavior:

- reads `LATEST_CLI_VERSION` env var from the API process
- if unset or empty → returns `outdated: false` (kill-switch / safe default; no update is advertised)
- if set → compares `current` against `LATEST_CLI_VERSION` using semver; `outdated = current < latest`
- `tarballUrl` is only included when `outdated: true`; it points at the version-specific GitHub Releases asset

Response shape:

```json
{
"latest": "0.2.11",
"outdated": true,
"tarballUrl": "https://github.com/distroinfinity/devdrip/releases/download/cli-v0.2.11/distrotv-cli.tar.gz"
}
```

When `outdated: false`:

```json
{ "latest": "0.2.11", "outdated": false }
```

When `LATEST_CLI_VERSION` is unset:

```json
{ "latest": null, "outdated": false }
```

Operational note: set `LATEST_CLI_VERSION` on the Railway API service after each CLI release to trigger auto-updates. Unset or lower it to halt a rollout. Tarballs remain on GitHub Releases — only the version signal lives here.

## `GET /auth/github/redirect`

> **Historical.** This endpoint was the S1-era OAuth start route. It was removed in M1, re-introduced (with different shape) in the 2026-05-22 github-oauth cutover where the dashboard owns `/auth/github/start` and `/auth/github/callback`, and the API exposes `/auth/github/complete` (s2s only). Treat this section as archived; see [architecture/auth.md](../architecture/auth.md) for the current flow.
Expand Down
10 changes: 7 additions & 3 deletions gitbook-docs/cli/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,21 @@ Fetches one real slot from `/me/content/next`, renders with a `[DEMO]` badge. Va

## `distro upgrade`

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.
Downloads, verifies, and activates the latest release. 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`.

The tarball URL comes from the server's update-check response (see Auto-update below), not a cached local file — so `distro upgrade` always reflects the server's current `LATEST_CLI_VERSION`.

## 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.
The daemon runs the update check on boot and on its regular tick. It calls `GET /cli/version-check?current=<semver>` on our API, which returns `{ latest, outdated, tarballUrl }`. When `outdated: true` it runs the same download → verify → activate pipeline automatically, then restarts itself. No version-check file cache — the server call is cheap and the server is the authority.

**Server-side rollout control:** the API reads `LATEST_CLI_VERSION` env var. If unset or empty, the endpoint returns `outdated: false` (safe default — nothing auto-updates). Setting it to the target release version makes all connected clients see an update. To halt a rollout, unset or lower `LATEST_CLI_VERSION`; to release, bump it. Tarballs still live on GitHub Releases — only the version signal moved to our server.

**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.
**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.

## What is missing

Expand Down
6 changes: 4 additions & 2 deletions gitbook-docs/cli/daemon-and-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,15 @@ The save keybind `b` writes to `reading_pending` (local SQLite) and syncs to `/m

## 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.
The daemon checks for updates on boot and on its regular tick by calling `GET /cli/version-check?current=<semver>` on our API. The server returns `{ latest, outdated, tarballUrl }` — no local version-cache file; the server is the single source of truth. When `outdated: true` the daemon runs the full pipeline automatically: download the server-provided `tarballUrl` → 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.

**Server-driven rollout:** the API reads `LATEST_CLI_VERSION` env var. Unset → `outdated: false` (no update advertised, safe default). Set to the target version → all connected clients pick it up on their next tick. To halt a rollout, unset or lower `LATEST_CLI_VERSION`. Tarballs still come from GitHub Releases; only the version signal moved to the server.

**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.
**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.

## What's next

Expand Down
11 changes: 11 additions & 0 deletions gitbook-docs/cli/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ 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.11 (2026-05-27)

**Changed:** version check moved from GitHub Releases API to our own server.

- **server-driven update check:** the daemon (and `distro upgrade` / `distro status`) now calls `GET /cli/version-check?current=<semver>` on the Distro TV API instead of polling `api.github.com/repos/.../releases/latest`. The server returns `{ latest, outdated, tarballUrl }`; the CLI downloads the server-provided `tarballUrl` (still a GitHub Releases asset).
- **`LATEST_CLI_VERSION` rollout control:** the API env var gates whether any client sees an update. Unset → `outdated: false` (safe default, nothing auto-updates). Set to the released version → clients pick it up on their next tick. Unset or lower it to halt a rollout.
- **no version-check file cache:** the previous 7-day / 10-min local cache file was removed. The server call runs on the daemon's existing tick and is cheap.
- **`tar` bundled in tarball:** `tar` is now listed in tsup `noExternal` so it ships inside the release tarball. Previously it was resolved from `node_modules` at runtime and missing from the extracted bundle, which would have crashed the updater on a clean install.

**Operational requirement:** after tagging a release, set `LATEST_CLI_VERSION=<version>` on the Railway API service. Until that variable is set, no connected client auto-updates (intentional — gives you time to verify the build before rolling out).

## 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { meActivitySummaryRouter } from "./routes/me-activity-summary.js"
// removed: /me/watchlist/sparklines — historical price series fetched on
// demand from Yahoo (spec §12).
import { meNowPlayingRouter } from "./routes/me-now-playing.js"
import { cliVersionRouter } from "./routes/cli-version.js"
import { meRecentNewsRouter } from "./routes/me-recent-news.js"
import { ingestRouter } from "./routes/ingest.js"
import { adminRouter } from "./routes/admin.js"
Expand Down Expand Up @@ -71,6 +72,7 @@ app.use(globalLimiter)

app.use("/channels", channelsPublicRouter)
app.use("/tickers", tickersRouter)
app.use("/cli", cliVersionRouter)

app.use("/auth", authRouter)
// public — anon device registration retained for legacy v0.1.x CLIs (deprecated)
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export const env = {
.filter(Boolean)
)
},
get latestCliVersion(): string {
return process.env["LATEST_CLI_VERSION"] ?? ""
},
get slackWebhookUrl(): string | undefined {
const v = process.env["SLACK_WEBHOOK_URL"]
return v && v.length > 0 ? v : undefined
Expand Down
24 changes: 24 additions & 0 deletions packages/api/src/lib/__tests__/semver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest"
import { compareSemver } from "../semver.js"

describe("compareSemver", () => {
it("0.2.9 < 0.2.10 (double-digit patch)", () => {
expect(compareSemver("0.2.9", "0.2.10")).toBe(-1)
})

it("0.2.10 > 0.2.9", () => {
expect(compareSemver("0.2.10", "0.2.9")).toBe(1)
})

it("1.0.0 === 1.0.0", () => {
expect(compareSemver("1.0.0", "1.0.0")).toBe(0)
})

it("double-digit minor: 0.2.10 > 0.2.9", () => {
expect(compareSemver("0.2.10", "0.2.9")).toBe(1)
})

it("pre-release sorts below release: 1.0.0-beta < 1.0.0", () => {
expect(compareSemver("1.0.0-beta", "1.0.0")).toBe(-1)
})
})
34 changes: 34 additions & 0 deletions packages/api/src/lib/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// minimal semver comparator — covers the cases we control (x.y.z[-pre]).
// pre-release suffix sorts below the plain release, matching npm semver ordering.
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
const [aCore, aPre] = splitCoreAndPre(a)
const [bCore, bPre] = splitCoreAndPre(b)
const aParts = aCore.split(".").map(toIntSafe)
const bParts = bCore.split(".").map(toIntSafe)
const len = Math.max(aParts.length, bParts.length)
for (let i = 0; i < len; i++) {
const av = aParts[i] ?? 0
const bv = bParts[i] ?? 0
if (av < bv) return -1
if (av > bv) return 1
}
// same core version — pre-release sorts below plain release
if (aPre && !bPre) return -1
if (!aPre && bPre) return 1
if (aPre && bPre) {
if (aPre < bPre) return -1
if (aPre > bPre) return 1
}
return 0
}

function splitCoreAndPre(v: string): [string, string] {
const idx = v.indexOf("-")
if (idx === -1) return [v, ""]
return [v.slice(0, idx), v.slice(idx + 1)]
}

function toIntSafe(s: string): number {
const n = parseInt(s, 10)
return Number.isFinite(n) ? n : 0
}
75 changes: 75 additions & 0 deletions packages/api/src/routes/__tests__/cli-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import request from "supertest"
import express from "express"
import { cliVersionRouter } from "../cli-version.js"

function buildApp() {
const app = express()
app.use(express.json())
app.use("/cli", cliVersionRouter)
return app
}

describe("GET /cli/version-check", () => {
let originalEnv: string | undefined

beforeEach(() => {
originalEnv = process.env["LATEST_CLI_VERSION"]
})

afterEach(() => {
if (originalEnv === undefined) {
delete process.env["LATEST_CLI_VERSION"]
} else {
process.env["LATEST_CLI_VERSION"] = originalEnv
}
})

it("missing current → 400", async () => {
const app = buildApp()
const res = await request(app).get("/cli/version-check")
expect(res.status).toBe(400)
expect(res.body).toEqual({ error: "missing_current" })
})

it("empty current → 400", async () => {
const app = buildApp()
const res = await request(app).get("/cli/version-check?current=")
expect(res.status).toBe(400)
expect(res.body).toEqual({ error: "missing_current" })
})

it("LATEST_CLI_VERSION unset → kill-switch: outdated=false, tarballUrl=''", async () => {
delete process.env["LATEST_CLI_VERSION"]
const app = buildApp()
const res = await request(app).get("/cli/version-check?current=0.2.9")
expect(res.status).toBe(200)
expect(res.body).toEqual({ latest: "0.2.9", outdated: false, tarballUrl: "" })
})

it("LATEST_CLI_VERSION set, current outdated → outdated=true with tarballUrl", async () => {
process.env["LATEST_CLI_VERSION"] = "0.2.11"
const app = buildApp()
const res = await request(app).get("/cli/version-check?current=0.2.9")
expect(res.status).toBe(200)
expect(res.body).toEqual({
latest: "0.2.11",
outdated: true,
tarballUrl:
"https://github.com/distroinfinity/devdrip/releases/download/cli-v0.2.11/distrotv-cli.tar.gz",
})
})

it("LATEST_CLI_VERSION set, current up-to-date → outdated=false", async () => {
process.env["LATEST_CLI_VERSION"] = "0.2.11"
const app = buildApp()
const res = await request(app).get("/cli/version-check?current=0.2.11")
expect(res.status).toBe(200)
expect(res.body).toEqual({
latest: "0.2.11",
outdated: false,
tarballUrl:
"https://github.com/distroinfinity/devdrip/releases/download/cli-v0.2.11/distrotv-cli.tar.gz",
})
})
})
27 changes: 27 additions & 0 deletions packages/api/src/routes/cli-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Router } from "express"
import { compareSemver } from "../lib/semver.js"

export const cliVersionRouter: ReturnType<typeof Router> = Router()

const TARBALL_BASE = "https://github.com/distroinfinity/devdrip/releases/download/cli-v"

cliVersionRouter.get("/version-check", (req, res) => {
const current = typeof req.query["current"] === "string" ? req.query["current"].trim() : ""
if (!current) {
res.status(400).json({ error: "missing_current" })
return
}

const latest = process.env["LATEST_CLI_VERSION"] ?? ""

// kill-switch: env not set → don't advertise any update
if (!latest) {
res.json({ latest: current, outdated: false, tarballUrl: "" })
return
}

const outdated = compareSemver(current, latest) < 0
const tarballUrl = `${TARBALL_BASE}${latest}/distrotv-cli.tar.gz`

res.json({ latest, outdated, tarballUrl })
})
Loading
Loading