diff --git a/README.md b/README.md index f501636..1186dfd 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,15 @@ services: | `ZCODE_PANEL_ENABLED` | 关 | 设为 `1`/`true` 后,无界面的 `serve` 模式(含 Docker)额外启动一个本机 Web 面板 | | `ZCODE_PANEL_TOKEN` | 无 | 面板的访问令牌,**开启面板时必填**(不填则面板不启动,避免裸奔的控制接口) | | `ZCODE_PANEL_PORT` | `8090` | 面板端口(只监听 `127.0.0.1`) | +| `ZCODE_UPDATE_CHECK` | 开 | 设为 `off`/`0` 关闭启动时的「有新版」检查(只提示,不自动更新) | +| `ZCODE_UPDATE_SKIP` | 无 | 逗号分隔要忽略的版本,如 `v4.7.6,v4.7.7` | 套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 t 切换,会写回 config.yaml。 服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。面板上的「Stop proxy」只停代理,进程本身仍能正常退出(SIGTERM/SIGINT 和面板的 shutdown 都会先清掉后台定时器——自动领取、验证码池——再退出);在面板里登出会同时清掉运行中的凭据并停掉代理,避免登出后新请求还继续花旧账号的额度。 +**有新版提示**:`serve` 和 TUI 启动时会异步向 GitHub 查一次 latest release,最多多打一行日志(TUI 里按 u 可手动重查),不阻塞启动、不影响代理;离线、被挡、限流或返回格式变了都一律静默忽略。手动检查总会给你明确答复(「已是最新」或「检查不可用」)。容器里镜像是不可变的,所以提示给的是**当前运行时的拉取命令**(Docker 为 `docker compose pull && docker compose up -d`,Podman 为 `podman compose pull && podman compose up -d`;认不出运行时则只说「拉取新镜像后重建容器」),而不是自己去替换文件(release 目前也没有校验和,所以不做自动下载替换)。不想让它查就设 `ZCODE_UPDATE_CHECK=off`,某个版本太吵可以 `ZCODE_UPDATE_SKIP=v4.7.6` 忽略。 + **Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环: ```yaml diff --git a/README_EN.md b/README_EN.md index c00c54b..5241f2c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -185,11 +185,15 @@ The config file is `config.yaml` in the project root (auto-generated on first st | `ZCODE_PANEL_ENABLED` | off | Set to `1`/`true` to start a local web panel in headless `serve` mode (including Docker) | | `ZCODE_PANEL_TOKEN` | none | Access token for the panel, **required when the panel is enabled** (without it the panel does not start, so the control endpoints are never left open) | | `ZCODE_PANEL_PORT` | `8090` | Panel port (bound to `127.0.0.1` only) | +| `ZCODE_UPDATE_CHECK` | on | Set to `off`/`0` to disable the startup "new version" check (it only notifies, it never updates in place) | +| `ZCODE_UPDATE_SKIP` | none | Comma-separated tags to mute, e.g. `v4.7.6,v4.7.7` | The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with t, which writes the change back to config.yaml. Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. Stopping the proxy from the page does not keep the process alive: SIGTERM/SIGINT and the panel's own shutdown all clear the background timers (auto-claim, captcha pool) before exiting. Logging out from the page also clears the live credential and stops the proxy, so a logged-out account is not spent any further. +**Update notice**: on startup `serve` and the TUI ask GitHub once for the latest release, printing at most one extra log line (press u in the TUI to re-check manually). It never blocks startup and never affects the proxy: offline, blocked, rate-limited or unexpected answers are ignored silently. A manual check always answers — "already on the latest version" or "check unavailable". Container images are immutable, so the hint names the pull command of the **detected runtime** (Docker: `docker compose pull && docker compose up -d`, Podman: `podman compose pull && podman compose up -d`; when the runtime cannot be told apart it just says "pull the new image and recreate the container") rather than replacing files in place (release artifacts carry no checksums yet, so automatic download-and-replace is not offered). Set `ZCODE_UPDATE_CHECK=off` to disable the check, or `ZCODE_UPDATE_SKIP=v4.7.6` to mute a single tag. + **Reaching the panel from Docker**: the panel listens on the *container's own* `127.0.0.1`, so with the default bridge network `-p 8080:8080` does not expose it, and adding `-p 8090:8090` does not help either (that maps a non-loopback container address). On a Linux server, use host networking so the container shares the host's loopback: ```yaml diff --git a/src/index.ts b/src/index.ts index 32fea61..ff93799 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { type PanelServer, type PanelSettings, } from "./server/panel.js"; +import { checkForUpdate } from "./update/check.js"; import { readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -412,6 +413,13 @@ async function serve(configPath: string | undefined, debug: boolean): Promise { + if (result.kind === "update") console.log(` update: ${result.notice.text}`); + }); + let panelRuntime: PanelServer | null = null; const closePanel = (): void => { diff --git a/src/tui/app.ts b/src/tui/app.ts index 23e1785..38f0330 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -29,6 +29,7 @@ import { appendFileSync } from "node:fs"; import type { ProxyConfig } from "../config/types.js"; import type { ProviderId } from "../provider/types.js"; import { LogPane, type LogLevel } from "./log-pane.js"; +import { checkForUpdate, createUpdateCheckQueue } from "../update/check.js"; import { KeyParser, type KeyAction } from "./keys.js"; import { buildFrame, findRegion, type ClickAction, type ClickRegion, type Frame, type QuotaState } from "./frame.js"; @@ -235,6 +236,27 @@ export async function runTui(args: ServeArgs): Promise { scheduleRender(); } + // --- update notice (issue #60) ------------------------------------------- + // Same check the Android app runs at startup: silent on every failure, and + // the result lands in the Logs card (the panel surfaces it through the log + // tee for free). `u` re-runs it manually — an explicit request overrides + // ZCODE_UPDATE_CHECK=off and the muted-tag list, and always answers visibly. + // The queue keeps a press made while the startup check is still in flight + // instead of dropping it: that check's result may be up-to-date, skipped or + // unavailable, none of which is an answer to an explicit request. + const runUpdateCheck = createUpdateCheckQueue(async (manual) => { + const result = await checkForUpdate(VERSION, { force: manual }); + if (result.kind === "update") { + emit(`update: ${result.notice.text}`, "info"); + if (manual) setToast(`update available: ${result.notice.latest}`, "info"); + return; + } + if (!manual) return; + if (result.kind === "up-to-date") setToast(`already on the latest version (v${VERSION})`, "ok"); + else if (result.kind === "skipped") setToast(`v${VERSION} is muted via ZCODE_UPDATE_SKIP`, "info"); + else setToast("update check unavailable (offline, or GitHub blocked)", "err"); + }); + // --- auth ---------------------------------------------------------------- async function refreshAuth(): Promise { const cred = await loadCredential().catch(() => null); @@ -593,6 +615,7 @@ export async function runTui(args: ServeArgs): Promise { case "l": void startLogin(); return; case "o": void logout(); return; case "r": void refreshQuota(); return; + case "u": void runUpdateCheck(true); return; case "p": switchProvider(); return; case "t": switchPlan(); return; case "c": pane.clear(); scheduleRender(); return; @@ -677,6 +700,7 @@ export async function runTui(args: ServeArgs): Promise { // --- boot --------------------------------------------------------------------- console.log(`zcode-proxy TUI — config: ${path}`); console.log(`provider: ${state.provider} · plan: ${state.plan}`); + void runUpdateCheck(); await refreshAuth(); renderNow(); if (!state.loggedIn) { diff --git a/src/tui/frame.ts b/src/tui/frame.ts index 3c4f3dc..65a0a93 100644 --- a/src/tui/frame.ts +++ b/src/tui/frame.ts @@ -621,7 +621,7 @@ export function buildFrame(s: FrameState): Frame { // narrow terminals, so the primary actions must come first. const footerItems: Array<[string, string]> = [ ["s", "start/stop"], ["l", "login"], ["o", "logout"], ["g", "follow"], ["q", "quit"], - ["p", "provider"], ["t", "plan"], ["r", "quota refresh"], ["c", "clear"], + ["p", "provider"], ["t", "plan"], ["r", "quota refresh"], ["u", "update"], ["c", "clear"], ]; footerParts.push({ t: "↑↓ scroll", c: DIM }); for (const [key, label] of footerItems) { diff --git a/src/update/check.test.ts b/src/update/check.test.ts new file mode 100644 index 0000000..6f2f13b --- /dev/null +++ b/src/update/check.test.ts @@ -0,0 +1,359 @@ +/** + * Tests for `src/update/check.ts` — the CLI/TUI update notice (issue #60). + * + * Every case injects a mock transport: the suite never touches the network, + * and the "silent failure" contract (which the Android checker documents, and + * which is why this module deliberately has no throwing path) is what most of + * these assertions are about. + */ +import { describe, it, expect } from "bun:test"; +import { + LATEST_RELEASE_API, + RELEASES_PAGE, + buildUpdateNotice, + checkForUpdate, + createUpdateCheckQueue, + detectContainerRuntime, + fetchLatestRelease, + isContainerRuntime, + isNewerVersion, + isSkippedVersion, + parseVersion, + updateCheckEnabled, + updateCommand, + type FetchLike, +} from "./check.js"; + +const RELEASE_URL = `${RELEASES_PAGE}/tag/v4.7.6`; +const BODY = { tag_name: "v4.7.6", html_url: RELEASE_URL, body: "release notes" }; + +function respondWith(body: unknown, status = 200): FetchLike { + return async () => ({ ok: status >= 200 && status < 300, status, json: async () => body }); +} + +function rejecting(): FetchLike { + return async () => { + throw new Error("getaddrinfo ENOTFOUND api.github.com"); + }; +} + +describe("isNewerVersion", () => { + it("detects a newer patch/minor/major release", () => { + expect(isNewerVersion("4.7.5", "v4.7.6")).toBe(true); + expect(isNewerVersion("4.7.5", "v4.8.0")).toBe(true); + expect(isNewerVersion("4.7.5", "v5.0.0")).toBe(true); + }); + + it("compares numerically, not as strings", () => { + expect(isNewerVersion("4.7.5", "v4.10.0")).toBe(true); + expect(isNewerVersion("4.9.0", "v4.10.0")).toBe(true); + expect(isNewerVersion("4.7.10", "v4.7.9")).toBe(false); + }); + + it("is false for the same or an older tag", () => { + expect(isNewerVersion("4.7.5", "v4.7.5")).toBe(false); + expect(isNewerVersion("4.7.5", "v4.7.4")).toBe(false); + expect(isNewerVersion("4.7.5", "4.7.5")).toBe(false); + }); + + it("accepts the variant suffixes this repository actually uses", () => { + // Real tags: v4.7.2.android, v4.5.4-AppOverhaul, v2.0.5.alpha, v1.4.7.alpha. + expect(isNewerVersion("4.7.5", "v4.7.6-android")).toBe(true); + expect(isNewerVersion("4.7.5", "v4.7.6-rc.1")).toBe(true); + expect(isNewerVersion("4.7.1", "v4.7.2.android")).toBe(true); + expect(isNewerVersion("4.5.4", "v4.5.4-AppOverhaul")).toBe(false); + expect(isNewerVersion("4.7.5", "v4.8")).toBe(true); + expect(isNewerVersion("4.7.5", "4.8")).toBe(true); + }); + + it("never reports a malformed tag as newer", () => { + expect(isNewerVersion("4.7.5", "latest")).toBe(false); + expect(isNewerVersion("4.7.5", "")).toBe(false); + expect(isNewerVersion("not-a-version", "v9.9.9")).toBe(false); + // Unseparated junk must not decay into a version core. + expect(isNewerVersion("4.7.5", "v4.7.6garbage")).toBe(false); + expect(isNewerVersion("4.7.5", "v4.7.6garbage-android")).toBe(false); + // A fourth numeric segment is not a version we understand. + expect(isNewerVersion("4.7.5", "v4.7.6.1")).toBe(false); + expect(isNewerVersion("4.7.5", "v4.7.6-")).toBe(false); + expect(isNewerVersion("4.7.5", "Windows")).toBe(false); + }); +}); + +describe("parseVersion", () => { + it("parses the numeric core and zero-fills missing segments", () => { + expect(parseVersion("v4.7.6")).toEqual([4, 7, 6]); + expect(parseVersion("4.8")).toEqual([4, 8, 0]); + expect(parseVersion("5")).toEqual([5, 0, 0]); + expect(parseVersion("v4.7.6-rc.1")).toEqual([4, 7, 6]); + expect(parseVersion("v4.7.2.android")).toEqual([4, 7, 2]); + }); + + it("returns null when there is no numeric core", () => { + expect(parseVersion("master")).toBeNull(); + expect(parseVersion("")).toBeNull(); + expect(parseVersion("v4.7.6garbage")).toBeNull(); + expect(parseVersion("v4.7.6.1")).toBeNull(); + }); +}); + +describe("updateCheckEnabled", () => { + it("is on by default and for explicit truthy values", () => { + expect(updateCheckEnabled({})).toBe(true); + expect(updateCheckEnabled({ ZCODE_UPDATE_CHECK: "" })).toBe(true); + expect(updateCheckEnabled({ ZCODE_UPDATE_CHECK: "1" })).toBe(true); + expect(updateCheckEnabled({ ZCODE_UPDATE_CHECK: "on" })).toBe(true); + }); + + it("is off for every documented off value, case/space insensitive", () => { + for (const value of ["0", "off", "OFF", " off ", "false", "no", "disabled"]) { + expect(updateCheckEnabled({ ZCODE_UPDATE_CHECK: value })).toBe(false); + } + }); +}); + +describe("isSkippedVersion", () => { + it("matches muted tags regardless of the v prefix and whitespace", () => { + expect(isSkippedVersion("v4.7.6", { ZCODE_UPDATE_SKIP: "v4.7.6" })).toBe(true); + expect(isSkippedVersion("4.7.6", { ZCODE_UPDATE_SKIP: " v4.7.6 , v4.7.7 " })).toBe(true); + expect(isSkippedVersion("v4.7.6-android", { ZCODE_UPDATE_SKIP: "4.7.6" })).toBe(true); + }); + + it("does not mute anything else", () => { + expect(isSkippedVersion("v4.7.6", {})).toBe(false); + expect(isSkippedVersion("v4.7.6", { ZCODE_UPDATE_SKIP: "v4.7.5" })).toBe(false); + expect(isSkippedVersion("v4.7.6", { ZCODE_UPDATE_SKIP: " " })).toBe(false); + // A malformed tag has no core, so it can neither be muted nor compared. + expect(isSkippedVersion("v4.7.6garbage", { ZCODE_UPDATE_SKIP: "v4.7.6" })).toBe(false); + }); +}); + +describe("detectContainerRuntime", () => { + it("attributes the Docker and Podman markers", () => { + expect(detectContainerRuntime({}, (p) => p === "/.dockerenv")).toBe("docker"); + expect(detectContainerRuntime({}, (p) => p === "/run/.containerenv")).toBe("podman"); + }); + + it("reads systemd's `container=` declaration", () => { + expect(detectContainerRuntime({ container: "podman" }, () => false)).toBe("podman"); + expect(detectContainerRuntime({ container: "docker" }, () => false)).toBe("docker"); + expect(detectContainerRuntime({ container: " Docker " }, () => false)).toBe("docker"); + }); + + it("reports an unknown container instead of guessing Docker", () => { + expect(detectContainerRuntime({ container: "lxc" }, () => false)).toBe("unknown"); + expect(detectContainerRuntime({ container: "systemd-nspawn" }, () => false)).toBe("unknown"); + }); + + it("is null on a bare host, and isContainerRuntime follows it", () => { + expect(detectContainerRuntime({}, () => false)).toBeNull(); + expect(detectContainerRuntime({ container: " " }, () => false)).toBeNull(); + expect(isContainerRuntime({}, (p) => p === "/.dockerenv")).toBe(true); + expect(isContainerRuntime({}, () => false)).toBe(false); + }); +}); + +describe("updateCommand", () => { + const release = { tag: "v4.7.6", url: RELEASE_URL, notes: null }; + + it("names a command that exists in the detected runtime", () => { + expect(updateCommand(release, "docker")).toBe("docker compose pull && docker compose up -d"); + expect(updateCommand(release, "podman")).toBe("podman compose pull && podman compose up -d"); + expect(updateCommand(release, "unknown")).toBe("pull the new image and recreate the container"); + expect(updateCommand(release, null)).toBe(`re-download from ${RELEASE_URL}`); + }); +}); + +describe("buildUpdateNotice", () => { + const release = { tag: "v4.7.6", url: RELEASE_URL, notes: null }; + + it("points container users at their runtime and binaries at the release asset", () => { + expect(buildUpdateNotice("4.7.5", release, "docker").text).toContain("docker compose pull && docker compose up -d"); + expect(buildUpdateNotice("4.7.5", release, "podman").text).toContain("podman compose pull && podman compose up -d"); + expect(buildUpdateNotice("4.7.5", release, "unknown").text).toContain("pull the new image and recreate the container"); + expect(buildUpdateNotice("4.7.5", release, null).text).toContain(`re-download from ${RELEASE_URL}`); + expect(buildUpdateNotice("4.7.5", release, null).text).toContain("v4.7.6 is available (you are on v4.7.5)"); + }); +}); + +describe("fetchLatestRelease", () => { + it("parses tag, url and notes from the releases API", async () => { + const release = await fetchLatestRelease({ fetchImpl: respondWith(BODY) }); + expect(release).toEqual({ tag: "v4.7.6", url: RELEASE_URL, notes: "release notes" }); + }); + + it("sends the User-Agent GitHub requires, plus a timeout signal", async () => { + let seenUrl = ""; + let seenInit: { headers?: Record; signal?: AbortSignal } | undefined; + const probe: FetchLike = async (url, init) => { + seenUrl = url; + seenInit = init; + return { ok: true, status: 200, json: async () => BODY }; + }; + await fetchLatestRelease({ fetchImpl: probe }); + expect(seenUrl).toBe(LATEST_RELEASE_API); + expect(seenInit?.headers?.["User-Agent"]).toBeTruthy(); + expect(seenInit?.headers?.Accept).toBe("application/vnd.github+json"); + expect(seenInit?.signal).toBeInstanceOf(AbortSignal); + }); + + it("falls back to the releases page when html_url is missing", async () => { + const release = await fetchLatestRelease({ fetchImpl: respondWith({ tag_name: "v4.7.6" }) }); + expect(release?.url).toBe(`${RELEASES_PAGE}/tag/v4.7.6`); + expect(release?.notes).toBeNull(); + }); + + it("returns null instead of throwing for every failure mode", async () => { + expect(await fetchLatestRelease({ fetchImpl: rejecting() })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith(BODY, 403) })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith(BODY, 404) })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith(BODY, 500) })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith(null) })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith({ tag_name: "" }) })).toBeNull(); + expect(await fetchLatestRelease({ fetchImpl: respondWith({ tag_name: 42 }) })).toBeNull(); + const badJson: FetchLike = async () => ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected end of JSON input"); + }, + }); + expect(await fetchLatestRelease({ fetchImpl: badJson })).toBeNull(); + }); +}); + +describe("checkForUpdate", () => { + it("reports an update with the runtime-aware command", async () => { + const result = await checkForUpdate("4.7.5", { + fetchImpl: respondWith(BODY), + env: {}, + containerRuntime: "docker", + }); + expect(result.kind).toBe("update"); + if (result.kind !== "update") return; + expect(result.notice.latest).toBe("v4.7.6"); + expect(result.notice.text).toContain("docker compose pull && docker compose up -d"); + }); + + it("uses the Podman command when Podman is the detected runtime", async () => { + const result = await checkForUpdate("4.7.5", { + fetchImpl: respondWith(BODY), + env: {}, + containerRuntime: "podman", + }); + expect(result.kind).toBe("update"); + if (result.kind !== "update") return; + expect(result.notice.text).toContain("podman compose pull && podman compose up -d"); + }); + + it("detects the runtime from the environment when not overridden", async () => { + const result = await checkForUpdate("4.7.5", { + fetchImpl: respondWith(BODY), + env: { container: "podman" }, + }); + expect(result.kind).toBe("update"); + if (result.kind !== "update") return; + expect(result.notice.text).toContain("podman compose pull && podman compose up -d"); + }); + + it("uses the download hint outside a container", async () => { + const result = await checkForUpdate("4.7.5", { fetchImpl: respondWith(BODY), env: {}, containerRuntime: null }); + expect(result.kind).toBe("update"); + if (result.kind !== "update") return; + expect(result.notice.text).toContain(RELEASE_URL); + }); + + it("is up-to-date for equal and older releases", async () => { + expect((await checkForUpdate("4.7.6", { fetchImpl: respondWith(BODY), env: {} })).kind).toBe("up-to-date"); + expect((await checkForUpdate("4.8.0", { fetchImpl: respondWith(BODY), env: {} })).kind).toBe("up-to-date"); + }); + + it("is up-to-date for a malformed tag instead of nagging", async () => { + const result = await checkForUpdate("4.7.5", { + fetchImpl: respondWith({ tag_name: "v4.7.6garbage" }), + env: {}, + }); + expect(result.kind).toBe("up-to-date"); + }); + + it("skips disabled checks without even calling the network", async () => { + let calls = 0; + const counting: FetchLike = async () => { + calls += 1; + return { ok: true, status: 200, json: async () => BODY }; + }; + const result = await checkForUpdate("4.7.5", { fetchImpl: counting, env: { ZCODE_UPDATE_CHECK: "off" } }); + expect(result.kind).toBe("unavailable"); + expect(calls).toBe(0); + }); + + it("honours the skip list, and a manual check overrides it", async () => { + const env = { ZCODE_UPDATE_SKIP: "v4.7.6" }; + expect((await checkForUpdate("4.7.5", { fetchImpl: respondWith(BODY), env })).kind).toBe("skipped"); + expect((await checkForUpdate("4.7.5", { fetchImpl: respondWith(BODY), env, force: true })).kind).toBe("update"); + // A manual check is also allowed while the automatic one is disabled. + const off = { ZCODE_UPDATE_CHECK: "off" }; + expect((await checkForUpdate("4.7.5", { fetchImpl: respondWith(BODY), env: off, force: true })).kind).toBe("update"); + }); + + it("reports unavailable when the network fails, never throwing", async () => { + const result = await checkForUpdate("4.7.5", { fetchImpl: rejecting(), env: {} }); + expect(result.kind).toBe("unavailable"); + }); +}); + +describe("createUpdateCheckQueue", () => { + function gate(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; + } + + it("runs immediately when idle", async () => { + const calls: boolean[] = []; + const queue = createUpdateCheckQueue(async (manual) => { + calls.push(manual); + }); + await queue(); + await queue(true); + expect(calls).toEqual([false, true]); + }); + + it("queues a manual check that arrives while one is in flight", async () => { + const pending = gate(); + const calls: boolean[] = []; + const queue = createUpdateCheckQueue(async (manual) => { + calls.push(manual); + if (calls.length === 1) await pending.promise; + }); + + const startup = queue(false); + const manual = queue(true); // `u` while the startup check is still running + expect(calls).toEqual([false]); // queued, not dropped + + pending.open(); + await Promise.all([startup, manual]); + expect(calls).toEqual([false, true]); // the queued manual run did happen + }); + + it("drops an automatic duplicate and coalesces repeated manual presses", async () => { + const pending = gate(); + const calls: boolean[] = []; + const queue = createUpdateCheckQueue(async (manual) => { + calls.push(manual); + if (calls.length === 1) await pending.promise; + }); + + const startup = queue(); + const duplicate = queue(); + const manual = queue(true); + const secondPress = queue(true); + expect(calls).toEqual([false]); + + pending.open(); + await Promise.all([startup, duplicate, manual, secondPress]); + expect(calls).toEqual([false, true]); + }); +}); diff --git a/src/update/check.ts b/src/update/check.ts new file mode 100644 index 0000000..7616942 --- /dev/null +++ b/src/update/check.ts @@ -0,0 +1,285 @@ +/** + * Update notice (issue #60) — the CLI/TUI counterpart of the Android app's + * `UpdateChecker`. The Android rules are ported as-is so both ends agree on + * what "newer" means: + * + * - ask the GitHub releases API for the newest tag (`/releases/latest` + * already excludes prereleases), + * - compare major.minor.patch numerically — never as strings, + * - fail silently: a blocked, slow or offline network must never affect + * startup, and a malformed release must not nag forever, + * - a manual check may still report "already latest" / "unavailable" to the + * caller; the automatic startup check stays quiet. + * + * Zero new dependencies (global fetch) and no long-lived timers: the request + * carries an `AbortController` deadline that is `unref`ed, so a stalled + * connection can neither hang a one-shot CLI nor keep a process alive. + * + * Container images are immutable, so "self-update" is not offered on either + * platform; the notice names the pull command of the detected runtime (Docker, + * Podman) and falls back to a runtime-agnostic "pull and recreate" hint when + * the container userland cannot be told apart. A real in-place updater would + * additionally need checksums published in the release (the artifacts have none + * today) — see the issue. + */ +import { existsSync } from "node:fs"; + +/** Newest stable release (the API excludes prereleases). */ +export const LATEST_RELEASE_API = "https://api.github.com/repos/TriDefender/zcode-api/releases/latest"; +/** Human-facing releases page, used for the "how to update" hint. */ +export const RELEASES_PAGE = "https://github.com/TriDefender/zcode-api/releases"; +/** + * GitHub answers 403 to requests without a User-Agent — the Android checker + * carries an explicit one for exactly this reason. + */ +const USER_AGENT = "zcode-proxy-update-check"; +const REQUEST_TIMEOUT_MS = 10_000; +/** `ZCODE_UPDATE_CHECK=off` disables the automatic startup check. */ +export const UPDATE_CHECK_ENV = "ZCODE_UPDATE_CHECK"; +/** `ZCODE_UPDATE_SKIP=v4.7.6,v4.7.7` mutes specific tags (Android's skipped_tag). */ +export const UPDATE_SKIP_ENV = "ZCODE_UPDATE_SKIP"; +const DISABLED_VALUES = new Set(["0", "false", "off", "no", "disable", "disabled"]); + +type Env = Record; + +/** + * Minimal fetch surface (structural): lets tests inject canned responses and + * failures without constructing a real `Response`. + */ +export type FetchLike = ( + input: string, + init?: { headers?: Record; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status?: number; json: () => Promise }>; + +export interface ReleaseInfo { + tag: string; + url: string; + notes: string | null; +} + +export interface UpdateNotice { + current: string; + latest: string; + url: string; + text: string; +} + +export type UpdateCheckResult = + | { kind: "update"; notice: UpdateNotice } + | { kind: "up-to-date"; latest: string } + | { kind: "skipped"; latest: string } + /** Offline, blocked, malformed answer, or the check is disabled. */ + | { kind: "unavailable" }; + +/** + * Container userland behind the running process, used to pick the update hint: + * `docker` and `podman` get their own command, `unknown` (a container marker we + * cannot attribute, e.g. `container=lxc`) gets a runtime-agnostic one, and a + * bare host (`null`) gets the release download link. + */ +export type ContainerRuntime = "docker" | "podman" | "unknown"; + +export interface UpdateCheckOptions { + /** Injected transport (tests). Defaults to the global `fetch`. */ + fetchImpl?: FetchLike; + timeoutMs?: number; + env?: Env; + /** Container detection override (tests); defaults to probing the markers. */ + containerRuntime?: ContainerRuntime | null; + /** Manual check: ignores `ZCODE_UPDATE_CHECK=off` and the skip list. */ + force?: boolean; +} + +/** Automatic check is on unless `ZCODE_UPDATE_CHECK` is an explicit off value. */ +export function updateCheckEnabled(env: Env = process.env): boolean { + return !DISABLED_VALUES.has((env[UPDATE_CHECK_ENV] ?? "").trim().toLowerCase()); +} + +/** + * Accepted release-tag shape: an optional `v`, one to three numeric segments, + * and an optional, explicitly separated variant suffix — `v4.7.5`, `4.8`, + * `v4.7.2.android`, `v4.5.4-AppOverhaul`, `v4.7.6-rc.1`. The end anchor is what + * keeps `v4.7.6garbage` (as well as `v4.7.6.1`, `Windows`, `latest`) from being + * read as `4.7.6` and nagging forever: no version core means "never newer". + */ +const VERSION_TAG = /^v?(\d+(?:\.\d+){0,2})(?:[.-][A-Za-z][0-9A-Za-z.-]*)?$/; + +/** `v4.7.2.android` → `4.7.2`; `""` when the tag is not a release version. */ +function versionCore(tag: string): string { + const match = VERSION_TAG.exec(tag.trim()); + return match ? match[1] : ""; +} + +/** Comma-separated muted tags; `v` prefixes and variant suffixes are ignored. */ +export function isSkippedVersion(tag: string, env: Env = process.env): boolean { + const wanted = versionCore(tag); + if (!wanted) return false; + return (env[UPDATE_SKIP_ENV] ?? "") + .split(",") + .map((part) => versionCore(part)) + .some((muted) => muted !== "" && muted === wanted); +} + +/** `[major, minor, patch]`, missing segments defaulting to 0; null when unparsable. */ +export function parseVersion(tag: string): [number, number, number] | null { + const core = versionCore(tag); + if (!core) return null; + const parts = core.split(".").map(Number); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** + * Android's rule (`UpdateChecker.isNewer`): compare the three numeric segments + * in order. An unparsable tag is never "newer" — otherwise a single malformed + * release would nag every user on every start. + */ +export function isNewerVersion(current: string, latest: string): boolean { + const running = parseVersion(current); + const candidate = parseVersion(latest); + if (!running || !candidate) return false; + for (let i = 0; i < 3; i++) { + if (candidate[i] !== running[i]) return candidate[i] > running[i]; + } + return false; +} + +/** + * Tell apart the container userland so the hint names a command that exists + * there. `/.dockerenv` is Docker's marker, `/run/.containerenv` is Podman's, + * and systemd exports `container=` inside containers (useful on Windows/WSL and + * for other runtimes). Anything else that declares itself a container is + * reported as `unknown` instead of being guessed as Docker. + */ +export function detectContainerRuntime( + env: Env = process.env, + exists: (path: string) => boolean = existsSync, +): ContainerRuntime | null { + if (exists("/.dockerenv")) return "docker"; + if (exists("/run/.containerenv")) return "podman"; + const declared = (env.container ?? "").trim().toLowerCase(); + if (declared.includes("podman")) return "podman"; + if (declared.includes("docker")) return "docker"; + return declared === "" ? null : "unknown"; +} + +/** Back-compat convenience wrapper around {@link detectContainerRuntime}. */ +export function isContainerRuntime( + env: Env = process.env, + exists: (path: string) => boolean = existsSync, +): boolean { + return detectContainerRuntime(env, exists) !== null; +} + +/** Update hint for the detected runtime; the image is immutable either way. */ +export function updateCommand(release: ReleaseInfo, runtime: ContainerRuntime | null): string { + switch (runtime) { + case "docker": + return "docker compose pull && docker compose up -d"; + case "podman": + return "podman compose pull && podman compose up -d"; + case "unknown": + return "pull the new image and recreate the container"; + default: + return `re-download from ${release.url}`; + } +} + +export function buildUpdateNotice( + current: string, + release: ReleaseInfo, + runtime: ContainerRuntime | null, +): UpdateNotice { + return { + current, + latest: release.tag, + url: release.url, + text: `${release.tag} is available (you are on v${current}) — ${updateCommand(release, runtime)}`, + }; +} + +/** + * Ask the releases API for the newest tag. Returns null for every failure mode + * — DNS/TLS/proxy interference, timeout, non-2xx (rate limit, 404), truncated + * or unexpected JSON — because the caller must never treat "cannot check" as an + * error (the Android checker documents the same contract). + */ +export async function fetchLatestRelease(opts: UpdateCheckOptions = {}): Promise { + const fetchImpl = opts.fetchImpl ?? (fetch as unknown as FetchLike); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? REQUEST_TIMEOUT_MS); + // A pending update check must never hold the process open. + const unref = (timer as unknown as { unref?: () => void }).unref; + if (typeof unref === "function") unref.call(timer); + try { + const response = await fetchImpl(LATEST_RELEASE_API, { + headers: { "User-Agent": USER_AGENT, Accept: "application/vnd.github+json" }, + signal: controller.signal, + }); + if (!response.ok) return null; + const body = (await response.json()) as { tag_name?: unknown; html_url?: unknown; body?: unknown } | null; + const tag = typeof body?.tag_name === "string" ? body.tag_name.trim() : ""; + if (!tag) return null; + const url = + typeof body?.html_url === "string" && body.html_url.length > 0 + ? body.html_url + : `${RELEASES_PAGE}/tag/${tag}`; + return { tag, url, notes: typeof body?.body === "string" ? body.body : null }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Full check for `current`: newest release → numeric compare → skip list → + * runtime-aware hint. Never throws. + */ +export async function checkForUpdate(current: string, opts: UpdateCheckOptions = {}): Promise { + const env = opts.env ?? process.env; + if (!opts.force && !updateCheckEnabled(env)) return { kind: "unavailable" }; + const release = await fetchLatestRelease(opts); + if (!release) return { kind: "unavailable" }; + if (!isNewerVersion(current, release.tag)) return { kind: "up-to-date", latest: release.tag }; + if (!opts.force && isSkippedVersion(release.tag, env)) return { kind: "skipped", latest: release.tag }; + const runtime = opts.containerRuntime !== undefined ? opts.containerRuntime : detectContainerRuntime(env); + return { kind: "update", notice: buildUpdateNotice(current, release, runtime) }; +} + +/** + * Single-flight scheduler for the startup / manual update check. While a check + * is running, another automatic call is dropped (the startup check answers it + * anyway) but a manual call is queued and re-run once the current request + * settles — pressing `u` during the startup check must still produce its own + * answer, and the startup result cannot be reused because it may have been + * disabled by `ZCODE_UPDATE_CHECK=off` or muted by `ZCODE_UPDATE_SKIP`. + * Repeated presses collapse into one queued check. + */ +export function createUpdateCheckQueue(run: (manual: boolean) => Promise): (manual?: boolean) => Promise { + let inFlight: Promise | null = null; + let manualQueued = false; + + async function start(manual: boolean): Promise { + const current = (async () => { + try { + await run(manual); + } finally { + inFlight = null; + } + })(); + inFlight = current; + await current; + if (manualQueued) { + manualQueued = false; + await start(true); + } + } + + return (manual = false) => { + if (inFlight) { + if (manual) manualQueued = true; + return inFlight; + } + return start(manual); + }; +}