diff --git a/CLAUDE.md b/CLAUDE.md index 51c1a8a4..a6475372 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. 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`. +- **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 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=` 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 diff --git a/gitbook-docs/backend/api.md b/gitbook-docs/backend/api.md index 98777488..866e4b2f 100644 --- a/gitbook-docs/backend/api.md +++ b/gitbook-docs/backend/api.md @@ -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. diff --git a/gitbook-docs/cli/current-state.md b/gitbook-docs/cli/current-state.md index 0e897d0b..6801e3c5 100644 --- a/gitbook-docs/cli/current-state.md +++ b/gitbook-docs/cli/current-state.md @@ -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 /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. +Downloads, verifies, and activates the latest release. 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`. + +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=` 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//` 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 diff --git a/gitbook-docs/cli/daemon-and-hooks.md b/gitbook-docs/cli/daemon-and-hooks.md index e55cdf9a..1b56bf01 100644 --- a/gitbook-docs/cli/daemon-and-hooks.md +++ b/gitbook-docs/cli/daemon-and-hooks.md @@ -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 /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. +The daemon checks for updates on boot and on its regular tick by calling `GET /cli/version-check?current=` 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 /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. + +**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 diff --git a/gitbook-docs/cli/releases.md b/gitbook-docs/cli/releases.md index 6fbdecf0..0c5bf791 100644 --- a/gitbook-docs/cli/releases.md +++ b/gitbook-docs/cli/releases.md @@ -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=` 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=` 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. diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 7428a88d..b5ae005a 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -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" @@ -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) diff --git a/packages/api/src/config/env.ts b/packages/api/src/config/env.ts index bb520c17..aa8c256e 100644 --- a/packages/api/src/config/env.ts +++ b/packages/api/src/config/env.ts @@ -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 diff --git a/packages/api/src/lib/__tests__/semver.test.ts b/packages/api/src/lib/__tests__/semver.test.ts new file mode 100644 index 00000000..217772e3 --- /dev/null +++ b/packages/api/src/lib/__tests__/semver.test.ts @@ -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) + }) +}) diff --git a/packages/api/src/lib/semver.ts b/packages/api/src/lib/semver.ts new file mode 100644 index 00000000..3208ff7d --- /dev/null +++ b/packages/api/src/lib/semver.ts @@ -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 +} diff --git a/packages/api/src/routes/__tests__/cli-version.test.ts b/packages/api/src/routes/__tests__/cli-version.test.ts new file mode 100644 index 00000000..1f563c31 --- /dev/null +++ b/packages/api/src/routes/__tests__/cli-version.test.ts @@ -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", + }) + }) +}) diff --git a/packages/api/src/routes/cli-version.ts b/packages/api/src/routes/cli-version.ts new file mode 100644 index 00000000..1cbf1538 --- /dev/null +++ b/packages/api/src/routes/cli-version.ts @@ -0,0 +1,27 @@ +import { Router } from "express" +import { compareSemver } from "../lib/semver.js" + +export const cliVersionRouter: ReturnType = 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 }) +}) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4ff09976..436878e3 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -10,7 +10,7 @@ import { showAd } from "../lib/daemon/display.js" import { cliVersion, refreshDeviceMetadata } from "../lib/device.js" import { clearStatusLine } from "../lib/statusline-state.js" import { setPendingUpdate } from "../lib/update-nudge.js" -import { maybeCheck } from "../lib/upgrade-check.js" +import { checkForUpdate } from "../lib/upgrade-check.js" import { createKeyCapture } from "../lib/daemon/input.js" import { acquireSingletonLock, @@ -38,7 +38,6 @@ import { pruneOldVersions, isVersionBad, markVersionBad, - TARBALL_URL, } from "../lib/auto-update.js" import { autoUpdateEnabled } from "../lib/auto-update-config.js" import { @@ -266,13 +265,13 @@ export async function runDaemon(): Promise { .catch((err) => log.warn("device metadata refresh failed", { error: (err as Error).message })) // periodic update check drives the status-line "update available" nudge. - // 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). + // every 15 min so a freshly-published release surfaces fast — the server + // call is cheap and stateless (no local cache). runs once on daemon boot, + // then on the interval. // 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()) + checkForUpdate(cliVersion()) .then(async (r) => { setPendingUpdate(r?.outdated ? r.latest : null) log.debug("update check", { outdated: r?.outdated ?? false, latest: r?.latest }) @@ -282,7 +281,7 @@ export async function runDaemon(): Promise { updateInFlight = true try { log.info("auto-update: downloading", { version: r.latest }) - const staged = await downloadAndStage(TARBALL_URL, r.latest) + const staged = await downloadAndStage(r.tarballUrl, r.latest) if (!(await verifyStaged(staged, r.latest))) { rmSync(staged, { recursive: true, force: true }) markVersionBad(r.latest) diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 8bb10a4e..1ca76a78 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -6,7 +6,7 @@ import { readConfig, type DevdripConfig } from "../lib/config.js" import { readDaemonStatus, type DaemonStatus } from "../lib/daemon/lifecycle.js" import { ledgerPath, openLedger } from "../lib/ledger.js" import { slotCachePath } from "../lib/slot-cache.js" -import { maybeCheck } from "../lib/upgrade-check.js" +import { checkForUpdate } from "../lib/upgrade-check.js" const require = createRequire(import.meta.url) @@ -67,7 +67,7 @@ export const statusCmd = new Command("status") async function runPassiveUpgradeCheck(): Promise<{ latest: string; outdated: boolean } | null> { try { const { version = "0.0.0" } = require("../package.json") as { version?: string } - return await maybeCheck(version, { timeoutMs: 500 }) + return await checkForUpdate(version, { timeoutMs: 500 }) } catch { return null } diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index d6a173b4..c5e12827 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -2,8 +2,8 @@ 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 { compareSemver, checkForUpdate } from "../lib/upgrade-check.js" +import { downloadAndStage, verifyStaged, activate } from "../lib/auto-update.js" import { readUpdateState, writeUpdateState } from "../lib/install-layout.js" const require = createRequire(import.meta.url) @@ -15,44 +15,43 @@ function currentVersion(): string { return version } -function printStatus(current: string, latest: string, cached: boolean): void { - const color = detectColor() - const cacheTag = cached ? dim(" (cached — checked within 7d)", color) : "" +function printStatus(current: string, latest: string): void { process.stdout.write(`current: ${current}\n`) - process.stdout.write(`latest: ${latest}${cacheTag}\n`) + process.stdout.write(`latest: ${latest}\n`) } export const upgradeCmd = new Command("upgrade") .description("check for updates and apply them if available") - .option("--force", "bypass the 7-day cache and fetch the latest release now") + // --force is still accepted for compatibility; no cache to bypass now — + // checkForUpdate always goes to the server, so force is a no-op semantically. + .option("--force", "re-check the server (always the case — no local cache)") .action(async (opts: { force?: boolean }) => { + void opts // force is a no-op; accepted for backward compatibility try { const current = currentVersion() - const result = await maybeCheck(current, { force: opts.force ?? false }) - if (!result) { - // only happens on the passive-check timeout path; direct `upgrade` - // invocations always fetch until success or throw. belt-and-suspenders. - console.log(`current: ${current}\nlatest: unknown (network timeout)`) + const info = await checkForUpdate(current) + if (!info) { + console.log(`current: ${current}\ncouldn't reach update server`) process.exit(0) } - printStatus(current, result.latest, result.cached) + printStatus(current, info.latest) const color = detectColor() - const cmp = compareSemver(current, result.latest) + const cmp = compareSemver(current, info.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))) { + const staged = await downloadAndStage(info.tarballUrl, info.latest) + if (!(await verifyStaged(staged, info.latest))) { rmSync(staged, { recursive: true, force: true }) console.error("update failed verification — not applied") process.exit(1) } - activate(staged, result.latest) + activate(staged, info.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` + `\n${green(`updated to ${info.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 @@ -73,6 +72,6 @@ export const upgradeCmd = new Command("upgrade") process.exit(0) } catch (err) { console.error(`upgrade check failed: ${(err as Error).message}`) - process.exit(opts.force ? 1 : 0) + process.exit(0) } }) diff --git a/packages/cli/src/lib/__tests__/upgrade-check.test.ts b/packages/cli/src/lib/__tests__/upgrade-check.test.ts index a6a083d4..e184e951 100644 --- a/packages/cli/src/lib/__tests__/upgrade-check.test.ts +++ b/packages/cli/src/lib/__tests__/upgrade-check.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest" -import { compareSemver, maybeCheck } from "../upgrade-check.js" +import { describe, expect, it, vi } from "vitest" +import { checkForUpdate, compareSemver } from "../upgrade-check.js" + +vi.mock("../api-client.js", () => ({ + apiFetchPublic: vi.fn(), +})) + +import { apiFetchPublic } from "../api-client.js" +const mockApiFetch = vi.mocked(apiFetchPublic) describe("compareSemver", () => { it.each([ @@ -19,51 +26,50 @@ describe("compareSemver", () => { }) }) -describe("maybeCheck", () => { - it("passive timeout returns null on network error", async () => { - const result = await maybeCheck("1.0.0", { - force: true, - timeoutMs: 10, - fetchLatest: (signal) => - new Promise((_resolve, reject) => { - signal?.addEventListener("abort", () => reject(new Error("aborted"))) - // never resolve — rely on the abort to settle - }), - now: () => 0, - }) - expect(result).toBeNull() - }) - - it("reports outdated when fetch returns a newer version", async () => { - const result = await maybeCheck("0.1.0", { - force: true, - fetchLatest: async () => "0.2.0", - now: () => 0, +describe("checkForUpdate", () => { + it("returns server payload when outdated", async () => { + mockApiFetch.mockResolvedValueOnce({ + latest: "0.2.0", + outdated: true, + tarballUrl: "https://example.com/distrotv-cli.tar.gz", }) - // writeCache may fail in an empty home dir; just assert shape + const result = await checkForUpdate("0.1.0") expect(result?.latest).toBe("0.2.0") expect(result?.outdated).toBe(true) - expect(result?.cached).toBe(false) + expect(result?.tarballUrl).toBe("https://example.com/distrotv-cli.tar.gz") + expect(mockApiFetch).toHaveBeenCalledWith("/cli/version-check", { + query: { current: "0.1.0" }, + timeoutMs: undefined, + }) }) - it("reports up-to-date when versions match", async () => { - const result = await maybeCheck("0.2.0", { - force: true, - fetchLatest: async () => "0.2.0", - now: () => 0, + it("returns server payload when up to date", async () => { + mockApiFetch.mockResolvedValueOnce({ + latest: "0.2.0", + outdated: false, + tarballUrl: "", }) + const result = await checkForUpdate("0.2.0") expect(result?.outdated).toBe(false) + expect(result?.tarballUrl).toBe("") + }) + + it("returns null on network failure", async () => { + mockApiFetch.mockRejectedValueOnce(new Error("network error")) + const result = await checkForUpdate("0.1.0") + expect(result).toBeNull() }) - it("throws on non-timeout failure when timeoutMs is undefined", async () => { - await expect( - maybeCheck("0.1.0", { - force: true, - fetchLatest: async () => { - throw new Error("boom") - }, - now: () => 0, - }) - ).rejects.toThrow(/boom/) + it("passes timeoutMs to apiFetchPublic", async () => { + mockApiFetch.mockResolvedValueOnce({ + latest: "0.1.0", + outdated: false, + tarballUrl: "", + }) + await checkForUpdate("0.1.0", { timeoutMs: 500 }) + expect(mockApiFetch).toHaveBeenCalledWith("/cli/version-check", { + query: { current: "0.1.0" }, + timeoutMs: 500, + }) }) }) diff --git a/packages/cli/src/lib/upgrade-check.ts b/packages/cli/src/lib/upgrade-check.ts index 467a2fb2..66cd3464 100644 --- a/packages/cli/src/lib/upgrade-check.ts +++ b/packages/cli/src/lib/upgrade-check.ts @@ -1,35 +1,27 @@ -import { randomBytes } from "node:crypto" -import { mkdir, readFile, rename, writeFile } from "node:fs/promises" -import { join } from "node:path" -import { configDir } from "./config.js" +import { apiFetchPublic } from "./api-client.js" -// the CLI ships via GitHub Releases (cli-v* tags), NOT npm — so the update -// check reads the latest release tag, not the npm registry. see cli/releases.md. -const RELEASES_URL = "https://api.github.com/repos/distroinfinity/devdrip/releases/latest" -const FETCH_TIMEOUT_MS = 1500 -// At our scale (<10 users) we want a freshly-published release nudged almost -// immediately, so the check is cheap + frequent. This cross-process cache only -// dedupes bursts (e.g. running `distro status` twice in a minute); the daemon -// re-runs the check every ~15 min (see daemon.ts), and this 10-min TTL is < that -// interval so each scheduled poll actually re-fetches. Overridden by --force. -export const CHECK_INTERVAL_MS = 10 * 60 * 1000 - -export function upgradeCheckPath(): string { - return join(configDir(), "upgrade-check.json") -} - -interface UpgradeCheckFile { - checkedAt: number - latestVersion: string -} - -export interface UpgradeCheckResult { +export interface UpdateInfo { latest: string outdated: boolean - // true when the result came out of the 7-day cache rather than the network. - // the cli prints the cached copy just as confidently — it's only stale if - // the registry published a release in the last week and we missed it. - cached: boolean + tarballUrl: string +} + +// ask OUR server whether this CLI is outdated. returns null on any network +// failure — passive callers (status.ts nudge) swallow null silently. +// no local cache: the server call is cheap and the daemon already rate-limits +// via its 15-min periodic tick. +export async function checkForUpdate( + current: string, + opts: { timeoutMs?: number } = {} +): Promise { + try { + return await apiFetchPublic("/cli/version-check", { + query: { current }, + timeoutMs: opts.timeoutMs, + }) + } catch { + return null + } } // Split on "." and numeric-compare part by part. pre-release suffixes @@ -69,124 +61,3 @@ function toIntSafe(s: string): number { const n = parseInt(s, 10) return Number.isFinite(n) ? n : 0 } - -export async function fetchLatestVersion(signal?: AbortSignal): Promise { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) - const composedSignal = signal ? anySignal([signal, controller.signal]) : controller.signal - try { - // GitHub requires a User-Agent; Accept pins the v3 JSON shape. - const res = await fetch(RELEASES_URL, { - signal: composedSignal, - headers: { Accept: "application/vnd.github+json", "User-Agent": "distro-cli" }, - }) - if (!res.ok) { - throw new Error(`github releases returned ${res.status}`) - } - const body = (await res.json()) as { tag_name?: unknown } - if (typeof body.tag_name !== "string" || body.tag_name.length === 0) { - throw new Error("github release response missing tag_name") - } - // tags look like "cli-v0.2.5" — strip the prefix to a bare semver. - return body.tag_name.replace(/^cli-v/, "").replace(/^v/, "") - } finally { - clearTimeout(timer) - } -} - -function anySignal(signals: AbortSignal[]): AbortSignal { - // Node 20+ has AbortSignal.any. fall back to a manual hook if older. - const any = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any - if (typeof any === "function") return any(signals) - const ctl = new AbortController() - for (const s of signals) { - if (s.aborted) { - ctl.abort(s.reason) - break - } - s.addEventListener("abort", () => ctl.abort(s.reason), { once: true }) - } - return ctl.signal -} - -export async function readUpgradeCheckCache(): Promise { - try { - const raw = await readFile(upgradeCheckPath(), "utf8") - const parsed = JSON.parse(raw) as Partial - if (typeof parsed.checkedAt !== "number" || typeof parsed.latestVersion !== "string") { - return null - } - return { checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion } - } catch { - return null - } -} - -export async function writeUpgradeCheckCache(data: UpgradeCheckFile): Promise { - const dir = configDir() - const target = upgradeCheckPath() - const tmp = join(dir, `.upgrade-check.${randomBytes(6).toString("hex")}.tmp`) - await mkdir(dir, { recursive: true, mode: 0o700 }) - await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }) - await rename(tmp, target) -} - -export interface MaybeCheckOpts { - force?: boolean - now?: () => number - // test seams — callers normally let these default - fetchLatest?: (signal?: AbortSignal) => Promise - timeoutMs?: number -} - -// maybeCheck returns null only when the caller passed a custom timeoutMs and -// the fetch was aborted before completing (used by status.ts's passive 500ms -// check). --force and direct invocations always return a result or throw. -export async function maybeCheck( - current: string, - opts: MaybeCheckOpts = {} -): Promise { - const now = opts.now ?? (() => Date.now()) - const doFetch = opts.fetchLatest ?? fetchLatestVersion - - if (!opts.force) { - const cache = await readUpgradeCheckCache() - if (cache && now() - cache.checkedAt < CHECK_INTERVAL_MS) { - return { - latest: cache.latestVersion, - outdated: compareSemver(current, cache.latestVersion) < 0, - cached: true, - } - } - } - - let latest: string - try { - if (opts.timeoutMs !== undefined) { - const ctl = new AbortController() - const timer = setTimeout(() => ctl.abort(), opts.timeoutMs) - try { - latest = await doFetch(ctl.signal) - } finally { - clearTimeout(timer) - } - } else { - latest = await doFetch() - } - } catch (err) { - if (opts.timeoutMs !== undefined) { - // passive path swallows errors — null means "don't show an upgrade line" - return null - } - throw err - } - - await writeUpgradeCheckCache({ checkedAt: now(), latestVersion: latest }).catch(() => { - // non-fatal: next run just re-fetches from the registry - }) - return { - latest, - outdated: compareSemver(current, latest) < 0, - cached: false, - } -} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 9bbc21c7..14d2c18b 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -19,6 +19,17 @@ export default defineConfig({ // stay external — they ship per-platform prebuilds via npm and can't be // bundled. Install.sh runs `npm install --omit=dev` against a stripped // runtime package.json (built in the release workflow) for those. - noExternal: ["@distrotv/shared", "@clack/prompts", "cli-table3", "commander", "qrcode-terminal"], + // `tar` is pure-JS and used by the auto-updater at runtime — it MUST be + // bundled, else the released CLI (which only `npm install`s better-sqlite3 + // from the stripped runtime package.json) crashes with "Cannot find package + // 'tar'" the moment auto-update runs. + noExternal: [ + "@distrotv/shared", + "@clack/prompts", + "cli-table3", + "commander", + "qrcode-terminal", + "tar", + ], external: ["better-sqlite3"], })