From e1141ca0100053cd225992552b345c18a7e7494c Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 27 Jul 2026 23:34:06 +0800 Subject: [PATCH] feat(core,server,web,cli): show version in the web UI with update check and admin self-update (#74) Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 10 + packages/cli/src/commands/serve.ts | 25 +- packages/cli/src/commands/update.ts | 42 +-- packages/cli/test/serve.test.ts | 19 ++ .../cli/test/update-refusal-markers.test.ts | 59 ++++ packages/core/src/index.ts | 4 + packages/core/src/internal/version.ts | 41 +++ packages/docs/content/configuration.en.md | 1 + packages/docs/content/configuration.zh.md | 1 + packages/docs/content/server-api.en.md | 10 + packages/docs/content/server-api.zh.md | 10 + packages/docs/content/web-app.en.md | 4 + packages/docs/content/web-app.zh.md | 4 + packages/server/src/api/types.ts | 55 ++++ packages/server/src/app.ts | 10 + packages/server/src/http/routes/version.ts | 154 +++++++++ .../src/services/update-check-service.ts | 141 +++++++++ packages/server/test/version.test.ts | 298 ++++++++++++++++++ packages/web/src/api/endpoints.ts | 15 + .../src/components/account/update-dialog.tsx | 103 ++++++ .../web/src/components/layout/sidebar.tsx | 134 +++++++- packages/web/src/features/chat/draft-view.tsx | 55 ++++ packages/web/src/lib/format.ts | 36 +++ packages/web/src/lib/strings-en.ts | 24 ++ packages/web/src/lib/strings.ts | 24 ++ packages/web/src/lib/use-version-info.ts | 113 +++++++ packages/web/test/format.test.ts | 23 ++ 27 files changed, 1375 insertions(+), 40 deletions(-) create mode 100644 packages/cli/test/update-refusal-markers.test.ts create mode 100644 packages/core/src/internal/version.ts create mode 100644 packages/server/src/http/routes/version.ts create mode 100644 packages/server/src/services/update-check-service.ts create mode 100644 packages/server/test/version.test.ts create mode 100644 packages/web/src/components/account/update-dialog.tsx create mode 100644 packages/web/src/lib/use-version-info.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7af6e01..e59d192 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,12 +88,17 @@ jobs: # Inject the release tag into core's VERSION constant (the source for CLI --version and the install-complete # message); otherwise artifacts always carry the in-repo dev version and multiple installs can't be told apart. + # BUILD_DATE is stamped alongside it with this run's UTC date (the repo keeps null for dev builds). - name: Stamp release version run: | TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" V="${TAG#v}" grep -q 'export const VERSION = "' packages/core/src/index.ts sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts + D="$(date -u +%Y-%m-%d)" + # BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation). + grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts + sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts - name: Build (tsup + vite) run: pnpm build @@ -279,12 +284,17 @@ jobs: # Version always comes from the tag: package version and core's VERSION constant are injected together (the # repo keeps the dev version). All published packages must bump in lockstep -- pnpm publish rewrites every # workspace:* dep to the dependency's current version, so the versions must match. + # BUILD_DATE is stamped alongside VERSION with this run's UTC date (the repo keeps null for dev builds). - name: Stamp release version run: | TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" V="${TAG#v}" grep -q 'export const VERSION = "' packages/core/src/index.ts sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts + D="$(date -u +%Y-%m-%d)" + # BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation). + grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts + sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts (cd packages/skills && npm version --no-git-tag-version --allow-same-version "$V") (cd packages/core && npm version --no-git-tag-version --allow-same-version "$V") (cd packages/server && npm version --no-git-tag-version --allow-same-version "$V") diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 78133e5..30cf333 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -10,10 +10,12 @@ * in parallel. Port/host priority: command-line option > existing environment variable * (including .env) > default 7364 / 127.0.0.1. `penguin web` additionally polls until the * service is ready, prints the URL, and opens a browser per-platform (`--no-open` - * disables this). + * disables this). Before the import, PENGUIN_CLI_ENTRY is exported (when node can re-run + * this entry) so the server's admin self-update endpoint can invoke `penguin update`. * Docs: /docs/cli § "penguin server / penguin web". */ import { spawn } from "node:child_process"; +import path from "node:path"; import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core"; import type { Command } from "commander"; import type { Messages } from "../i18n.js"; @@ -55,6 +57,18 @@ export function browserUrl(host: string, port: number): string { return `http://${target}:${port}/`; } +/** + * The CLI entry script advertised to the server for the admin self-update endpoint + * (POST /api/version/update re-runs it as `node update --yes`), or null when it + * must not be advertised. Only entries plain `node` can execute qualify: a dev CLI run + * through tsx has a .ts entry that node would reject with a syntax error, and reporting + * "unsupported" beats a misleading failure. Exported for unit tests. + */ +export function cliEntryFor(argv1: string | undefined): string | null { + if (!argv1) return null; + return /\.(js|mjs|cjs)$/i.test(argv1) ? path.resolve(argv1) : null; +} + /** * Sets PORT / HOST then starts the service: the server entry point only reads * process.env, and its dotenv loading never overrides existing environment variables, @@ -69,6 +83,15 @@ async function startServer(opts: { const host = opts.host ?? process.env.HOST ?? DEFAULT_HOST; process.env.PORT = String(port); process.env.HOST = host; + // Tell the server which CLI entry script launched it: the admin self-update endpoint + // (POST /api/version/update) re-runs `node update --yes`. Set before the import + // so it is visible however the server captures its environment; when the server was not + // started through the CLI (or the entry is not re-runnable by plain node, e.g. a tsx dev + // run) the variable stays unset and the endpoint reports "unsupported". + const cliEntry = cliEntryFor(process.argv[1]); + if (cliEntry !== null) { + process.env.PENGUIN_CLI_ENTRY = cliEntry; + } await import("@prismshadow/penguin-server"); return { host, port }; } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 16c8b7a..ce00498 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -58,10 +58,15 @@ import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { realpathSync } from "node:fs"; -import { VERSION } from "@prismshadow/penguin-core"; +import { VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core"; import type { Command } from "commander"; import type { Messages } from "../i18n.js"; +// The version helpers live in core (internal/version.ts) so the server's update-check +// endpoint shares them; re-exported because they are part of this module's public, +// unit-tested surface. +export { compareVersions, normalizeVersion }; + /** Repository the released artifacts come from — the same repo install.sh downloads from. */ export const REPO_SLUG = "Prism-Shadow/penguin-harness"; /** Releases API endpoint for the newest published release. */ @@ -165,41 +170,6 @@ export function globalInstallCommand( return { command: "npm", args: ["install", "-g", spec] }; } -/** Strips a leading `v` so `v0.1.2` and `0.1.2` are the same input. */ -export function normalizeVersion(tag: string): string { - return tag.trim().replace(/^v/i, ""); -} - -/** - * Compares two dotted numeric versions: -1 / 0 / 1. - * - * Each dot-separated component is read with `Number.parseInt`, which takes the leading digits and - * ignores the rest: `1abc` is 1, and `2-rc1` is 2. A component with no leading digit at all, or - * one that is missing entirely, counts as 0 — which is the property that matters, because it means - * a malformed or truncated tag can never make an upgrade look available. - * - * The consequence, stated rather than papered over: suffixes are invisible here, so `0.1.2-rc1` - * compares *equal* to `0.1.2`. This project tags plain `vX.Y.Z` releases only, and the API this - * reads (`tag_name` from GitHub Releases) returns those tags, so the case does not arise; carrying - * a full semver precedence implementation — with its own numeric-vs-alphanumeric identifier rules - * — to handle tags we do not publish would be more code and more ways to be wrong. If pre-release - * tags are ever published, this has to become a real semver compare before `--release` can target - * one. - */ -export function compareVersions(a: string, b: string): number { - const parse = (v: string) => - normalizeVersion(v) - .split(".") - .map((n) => Number.parseInt(n, 10)); - const [x, y] = [parse(a), parse(b)]; - for (let i = 0; i < Math.max(x.length, y.length); i += 1) { - const l = Number.isFinite(x[i]) ? (x[i] as number) : 0; - const r = Number.isFinite(y[i]) ? (y[i] as number) : 0; - if (l !== r) return l < r ? -1 : 1; - } - return 0; -} - /** * Builds the argv and environment for re-running install.sh, preserving the shape of the install * being upgraded rather than the defaults: diff --git a/packages/cli/test/serve.test.ts b/packages/cli/test/serve.test.ts index deffc4a..b67cb23 100644 --- a/packages/cli/test/serve.test.ts +++ b/packages/cli/test/serve.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import path from "node:path"; import { Command } from "commander"; import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core"; import { @@ -6,6 +7,7 @@ import { DEFAULT_PORT, browserCommand, browserUrl, + cliEntryFor, registerServeCommands, resolvePort, } from "../src/commands/serve.js"; @@ -74,3 +76,20 @@ describe("registerServeCommands (command registration)", () => { expect(web.opts().open).toBe(true); }); }); + +describe("cliEntryFor (the entry advertised for the web self-update)", () => { + it("advertises only entries plain node can re-run (.js/.mjs/.cjs), resolved absolute", () => { + // Expectations go through path.resolve too: on win32 an absolute POSIX-style input + // gains a drive prefix and backslashes, and the contract is "resolved", not a literal. + expect(cliEntryFor("/opt/penguin/lib/dist/index.js")).toBe( + path.resolve("/opt/penguin/lib/dist/index.js"), + ); + expect(cliEntryFor("/x/cli.MJS")).toBe(path.resolve("/x/cli.MJS")); + expect(cliEntryFor("/x/cli.cjs")).toBe(path.resolve("/x/cli.cjs")); + }); + it("refuses a tsx dev entry and a missing argv[1] (the endpoint then reports unsupported)", () => { + expect(cliEntryFor("/repo/packages/cli/src/index.ts")).toBeNull(); + expect(cliEntryFor(undefined)).toBeNull(); + expect(cliEntryFor("")).toBeNull(); + }); +}); diff --git a/packages/cli/test/update-refusal-markers.test.ts b/packages/cli/test/update-refusal-markers.test.ts new file mode 100644 index 0000000..c15d53d --- /dev/null +++ b/packages/cli/test/update-refusal-markers.test.ts @@ -0,0 +1,59 @@ +/** + * Drift guard for the server's self-update classifier. + * + * POST /api/version/update (packages/server/src/http/routes/version.ts) tells a refusal + * ("unsupported") apart from a successful run by matching `penguin update` output against + * REFUSAL_MARKERS — five English fragments duplicated from this package's i18n catalog. + * The server cannot import the CLI (the dependency runs the other way), so the fragments + * are hardcoded here too, on purpose: editing a refusal message without updating + * REFUSAL_MARKERS would silently misclassify refusals as "updated", and this test turns + * that drift into a loud failure at edit time. If it fails, change the marker in + * packages/server/src/http/routes/version.ts REFUSAL_MARKERS together with the string + * (or keep the fragment intact in the new wording). + */ +import { describe, expect, it } from "vitest"; +import { getMessages } from "../src/i18n.js"; + +const en = getMessages("en").update; + +/** + * Keep in sync with REFUSAL_MARKERS in packages/server/src/http/routes/version.ts — + * one marker per refusal message, paired with the en output that must contain it. + * (`needsYes` / `cancelled` are not refusals the server can see: it always runs with + * `--yes`.) The argument values are arbitrary; markers must not depend on them. + */ +const MARKERS: ReadonlyArray<{ marker: string; name: string; message: string }> = [ + { + marker: "runs from a source checkout", + name: "sourceCheckout", + message: en.sourceCheckout(), + }, + { + marker: "Cannot tell how this penguin was installed", + name: "unknownInstall", + message: en.unknownInstall("/opt/penguin/cli.js"), + }, + { + marker: "could not be identified", + name: "npmUnknownManager", + message: en.npmUnknownManager("/usr/lib/node_modules", "1.2.3"), + }, + { + marker: "does not run on Windows", + name: "windowsUnsupported", + message: en.windowsUnsupported(), + }, + { + marker: "cannot run your package manager for you", + name: "windowsGlobalInstall", + message: en.windowsGlobalInstall("npm install -g @prismshadow/penguin-cli@1.2.3"), + }, +]; + +describe("update refusal markers (server classifier contract)", () => { + for (const { marker, name, message } of MARKERS) { + it(`en update.${name} still contains its server-side marker`, () => { + expect(message).toContain(marker); + }); + } +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ddf735..c3d9aeb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,3 +52,7 @@ export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } f /** SDK version number. */ export const VERSION = "0.1.2"; +/** Release build date (UTC yyyy-mm-dd), stamped by the release workflow next to VERSION; null in a dev/source build. */ +export const BUILD_DATE: string | null = null; +// Version-string helpers (shared by the CLI's `penguin update` and the server's update check). +export { compareVersions, normalizeVersion } from "./internal/version.js"; diff --git a/packages/core/src/internal/version.ts b/packages/core/src/internal/version.ts new file mode 100644 index 0000000..190ffd3 --- /dev/null +++ b/packages/core/src/internal/version.ts @@ -0,0 +1,41 @@ +/** + * Version-string helpers shared by everything that talks about releases: the CLI's + * `penguin update` command and the server's update-check endpoint (the web UI's update + * reminder). Moved here from the CLI so the server does not need a dependency on the CLI + * package; the barrel re-exports both functions. + */ + +/** Strips a leading `v` so `v0.1.2` and `0.1.2` are the same input. */ +export function normalizeVersion(tag: string): string { + return tag.trim().replace(/^v/i, ""); +} + +/** + * Compares two dotted numeric versions: -1 / 0 / 1. + * + * Each dot-separated component is read with `Number.parseInt`, which takes the leading digits and + * ignores the rest: `1abc` is 1, and `2-rc1` is 2. A component with no leading digit at all, or + * one that is missing entirely, counts as 0 — which is the property that matters, because it means + * a malformed or truncated tag can never make an upgrade look available. + * + * The consequence, stated rather than papered over: suffixes are invisible here, so `0.1.2-rc1` + * compares *equal* to `0.1.2`. This project tags plain `vX.Y.Z` releases only, and the API this + * reads (`tag_name` from GitHub Releases) returns those tags, so the case does not arise; carrying + * a full semver precedence implementation — with its own numeric-vs-alphanumeric identifier rules + * — to handle tags we do not publish would be more code and more ways to be wrong. If pre-release + * tags are ever published, this has to become a real semver compare before the CLI's `--release` + * flag can target one. + */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => + normalizeVersion(v) + .split(".") + .map((n) => Number.parseInt(n, 10)); + const [x, y] = [parse(a), parse(b)]; + for (let i = 0; i < Math.max(x.length, y.length); i += 1) { + const l = Number.isFinite(x[i]) ? (x[i] as number) : 0; + const r = Number.isFinite(y[i]) ? (y[i] as number) : 0; + if (l !== r) return l < r ? -1 : 1; + } + return 0; +} diff --git a/packages/docs/content/configuration.en.md b/packages/docs/content/configuration.en.md index 6101d6f..6d4590e 100644 --- a/packages/docs/content/configuration.en.md +++ b/packages/docs/content/configuration.en.md @@ -18,6 +18,7 @@ The CLI and the server automatically load a `.env` file from the working directo | `PENGUIN_WEB_DIST` | Front-end static assets directory | the npm server package falls back to its bundled web-dist | | `PENGUIN_PREVIEW_ORIGIN` | Origin that serves Workspace HTML previews, e.g. `https://preview.example.com` | unset — the loopback counterpart is derived per request | | `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` | +| `PENGUIN_UPDATE_CHECK` | `off` disables the web app's new-release check (the server's only outbound internet call) | enabled | `PENGUIN_PREVIEW_ORIGIN` must differ from the app's origin by **hostname**, not just port: cookies ignore ports, so a second port would still share the session cookie. Leave it unset for local use — the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`, which needs no configuration and no DNS. Set it when the app is reached over a LAN address or a real domain; otherwise previews there fall back to a same-origin sandbox where `localStorage`, cookies and third-party embeds do not work. When you do set it on a real domain, keep the session cookie host-only (no `Domain=`), or a sibling subdomain shares it. An unparseable value is a startup error rather than a silent fallback. diff --git a/packages/docs/content/configuration.zh.md b/packages/docs/content/configuration.zh.md index aaa969d..03516ad 100644 --- a/packages/docs/content/configuration.zh.md +++ b/packages/docs/content/configuration.zh.md @@ -18,6 +18,7 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。 | `PENGUIN_WEB_DIST` | 前端静态资源目录 | npm 安装的服务端包回退到内置 web-dist | | `PENGUIN_PREVIEW_ORIGIN` | 提供 Workspace HTML 预览的独立源,如 `https://preview.example.com` | 未设置,按请求推导回环对应名 | | `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` | +| `PENGUIN_UPDATE_CHECK` | 设为 `off` 关闭 Web 应用的新版本检查(服务端唯一的对外网络请求) | 开启 | `PENGUIN_PREVIEW_ORIGIN` 必须与应用源在**主机名**上不同,只换端口不行:Cookie 不区分端口,换端口仍然共用会话 Cookie。本地使用不必配置——App 固定在规范主机 `localhost`,预览用 `127.0.0.1`,既不需要配置也不需要 DNS。经 LAN 地址或真实域名访问时才需要设置,否则那里的预览会回退到同源沙箱,`localStorage`、Cookie 与第三方 embed 都不可用。在真实域名上设置时,会话 Cookie 必须保持 host-only(不带 `Domain=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。 diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 673577c..8d31594 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -66,6 +66,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/admin/users/:userId/password | Reset a password (invalidates all of that user's login sessions) | | DELETE | /api/admin/users/:userId | Delete a user | +### Version and Self-Update + +| Method | Path | Description | +| --- | --- | --- | +| GET | /api/version | Running release identity: `{version, buildDate}` (`buildDate` is the running version's release date, stamped at build time — no network; null in a dev/source build or a release that predates the stamping) | +| GET | /api/version/update-check | Compares the newest GitHub release with the running version: `{currentVersion, latestVersion, updateAvailable, releaseUrl, publishedAt, checkedAt, disabled?, error?}`; `?force=1` (the manual "check for updates" action) bypasses the TTL cache, and the outcome is cached as usual | +| POST | /api/version/update | **Admin only.** Runs the CLI self-update (`penguin update --yes`) on the server host: `{status, output, needsRestart}` | + +`update-check` is the server's only outbound internet call and is strictly fail-soft: a failed lookup still returns 200 with `error` set (`network` / `rate_limited` / `bad_response`) and `latestVersion: null`, results are cached in memory (success 1 h, failure 10 min), and setting `PENGUIN_UPDATE_CHECK=off` disables the lookup entirely (`disabled: true`, no network call). The update `status` is `updated` (restart the service to run the new version), `failed`, or `unsupported` — the latter both when the server was not started via `penguin server|web` (`reason: "not_launched_via_cli"`) and when the CLI refuses (source checkout, unrecognized install layout, Windows); `output` carries the tail of the CLI's own output. + ### Projects and Members | Method | Path | Description | diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index a631a5e..7ea8ecc 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -66,6 +66,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) | | DELETE | /api/admin/users/:userId | 删除用户 | +### 版本与在线更新 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | /api/version | 当前运行版本:`{version, buildDate}`(`buildDate` 是当前运行版本的发布日期,构建时打入、无需联网;开发/源码构建以及打入机制之前的发布版为 null) | +| GET | /api/version/update-check | 对比 GitHub 最新 Release 与当前版本:`{currentVersion, latestVersion, updateAvailable, releaseUrl, publishedAt, checkedAt, disabled?, error?}`;`?force=1`(手动「检查更新」)绕过 TTL 缓存,结果照常写入缓存 | +| POST | /api/version/update | **仅管理员。**在服务器上执行 CLI 在线更新(`penguin update --yes`):`{status, output, needsRestart}` | + +`update-check` 是服务端唯一的对外网络请求,并且严格失败兜底:查询失败仍返回 200,只是设置 `error`(`network` / `rate_limited` / `bad_response`)且 `latestVersion` 为 null;结果在内存中缓存(成功 1 小时、失败 10 分钟);设置 `PENGUIN_UPDATE_CHECK=off` 可完全关闭该查询(返回 `disabled: true`,不发起任何网络请求)。更新的 `status` 为 `updated`(需重启服务才能运行新版本)、`failed` 或 `unsupported` —— 后者包括服务不是通过 `penguin server|web` 启动(`reason: "not_launched_via_cli"`),以及 CLI 自身拒绝执行(源码运行、无法识别的安装方式、Windows);`output` 携带 CLI 输出的末尾片段。 + ### Project 与成员 | 方法 | 路径 | 说明 | diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index cfc4fc7..73aeb0d 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -100,6 +100,10 @@ Read-only scoreboards per Benchmark: switch the metric (score / cost / duration) Admin only: list and create users, reset passwords, and delete users (the built-in admin cannot be deleted). +## Version and Updates + +The sidebar user menu carries a manual "Check for updates" action directly below "Change password"; the running version sits muted on the right of that row, and its release date — stamped into the build by the release workflow, displayed without any network access — appears as the row's localized "Last updated Jul 26"-style tooltip (dev builds and releases that predate the stamping, v0.1.2 and earlier, have no date). The new-chat page shows the same identity as a version line under the brand. The app checks GitHub for a newer release once the menu has first been opened, and immediately — bypassing the cached result — when the manual action is clicked; the latter reports "You're on the latest version" when nothing newer exists. When a newer release is found, a dot appears on the user button, the version displays gain a small superscript "New version available" badge (the draft-page badge links to the release), and the menu gains a release-notes link, plus an "Update now" action for admins that runs `penguin update` on the server (the data directory is untouched). The service must be restarted afterwards for the update to take effect. Set `PENGUIN_UPDATE_CHECK=off` to disable the update check entirely — see the [Configuration Reference](/configuration). + ## Projects and Members The sidebar provides a Project switcher and supports creating new Projects. Members have two roles, owner and member: owners manage membership and exclusively edit models, Vault, and Schedules, as well as perform deletions. diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 44fba67..27ba486 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -100,6 +100,10 @@ penguin web 仅管理员可见:列出与创建用户、重置密码、删除用户(内置 admin 不可删除)。 +## 版本与更新 + +侧边栏用户菜单在「修改密码」下方提供手动「检查更新」按钮,当前运行版本号以浅色显示在该行右侧;其发布日期(由发布流程在构建时打入、直接显示、无需联网)以「最近更新日期 7 月 26 日」样式的悬浮提示展示(开发构建以及打入机制之前的发布版,v0.1.2 及以前,没有日期)。新对话页在品牌下方以版本行显示同样的信息。菜单首次打开后会向 GitHub 查询是否有新版本;点击「检查更新」则立即查询(绕过缓存结果),已是最新时以提示告知。发现新版本时,用户按钮上会出现提示圆点,版本显示处会出现「有新版本可用」上标小徽标(新对话页的徽标可跳转 Release 页面),菜单内提供更新说明链接;管理员还可点击"立即更新",在服务器上执行 `penguin update`(数据目录不受影响)。更新完成后需要重启服务才会生效。设置 `PENGUIN_UPDATE_CHECK=off` 可完全关闭新版本检查——见[配置参考](/configuration)。 + ## Project 与成员 侧边栏提供 Project 切换器,并支持创建新 Project。成员分为 Owner 与 Member 两种角色:Owner 负责成员管理,并独占模型、Vault、Schedule 的编辑以及各类删除操作。 diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 8cd2588..fb420d3 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -1250,3 +1250,58 @@ export interface AgentSkillsResponse { export interface SkillInstallRequest { names: string[]; } + +// --------------------------------------------------------------------------- +// Version and self-update +// --------------------------------------------------------------------------- + +/** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */ +export interface VersionResponse { + version: string; + /** + * The **running** version's release date (UTC yyyy-mm-dd), stamped into core's + * BUILD_DATE at build time by the release workflow — the web's "last updated" date + * needs no network. Null for a dev/source build and for releases that predate the + * stamping (v0.1.2 and earlier): the UI then shows the version alone. + */ + buildDate: string | null; +} + +/** + * GET /api/version/update-check: newest published release vs the running version. + * Always HTTP 200 (fail-soft): a lookup failure sets `error` and leaves `latestVersion` + * null rather than failing the request; results are cached server-side. + */ +export interface UpdateCheckResponse { + currentVersion: string; + /** Same as VersionResponse.buildDate: the running version's release date, stamped at build time. */ + buildDate: string | null; + /** Newest published release (normalized, no leading `v`); null when the lookup failed or checks are disabled. */ + latestVersion: string | null; + updateAvailable: boolean; + /** Release page of the newest release (for the "release notes" link). */ + releaseUrl: string | null; + /** Publish timestamp of the newest release (ISO 8601). */ + publishedAt: string | null; + /** When this result was produced (ISO 8601) — a cached result keeps its original timestamp. */ + checkedAt: string; + /** Present (true) when update checks are turned off via PENGUIN_UPDATE_CHECK=off; no network call was made. */ + disabled?: true; + /** Why the lookup failed: unreachable network / GitHub rate limit / unusable response. */ + error?: "network" | "rate_limited" | "bad_response"; +} + +/** + * POST /api/version/update (admin only): runs the CLI self-update (`penguin update --yes`) + * on the server host. `unsupported` covers both a server not launched via the CLI and the + * CLI's own refusals (source checkout, unrecognized install layout, Windows). + */ +export interface UpdateRunResponse { + status: "updated" | "failed" | "unsupported"; + /** Set when the server cannot run the CLI at all (started without `penguin server|web`). */ + reason?: "not_launched_via_cli"; + /** Tail of the update command's combined stdout+stderr (capped; empty when nothing ran). */ + output: string; + /** True when the install changed (or was already current): restart the service to run the new version. */ + needsRestart: boolean; +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 70df0ba..5d35c84 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -47,6 +47,7 @@ import { agentConfigRoutes } from "./http/routes/agent-config.js"; import { agentTracesRoutes } from "./http/routes/agent-traces.js"; import { usageRoutes } from "./http/routes/usage.js"; import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js"; +import { versionRoutes } from "./http/routes/version.js"; import { ChannelHub } from "./runtime/channel.js"; import { ErrorRecorder } from "./runtime/error-recorder.js"; import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js"; @@ -65,6 +66,7 @@ import { ProjectConfigService } from "./services/project-config-service.js"; import { ProjectService } from "./services/project-service.js"; import { SessionService } from "./services/session-service.js"; import { TraceService } from "./services/trace-service.js"; +import { UpdateCheckService } from "./services/update-check-service.js"; import { UsageService } from "./services/usage-service.js"; import { WorkspaceFilesService } from "./services/workspace-files-service.js"; import { @@ -93,6 +95,8 @@ export interface AppDeps { sessionService: SessionService; traceService: TraceService; usageService: UsageService; + /** GitHub latest-release lookup for the web UI's update reminder (cached, fail-soft). */ + updateCheck: UpdateCheckService; workspaceFiles: WorkspaceFilesService; /** Signs/verifies short-lived Workspace preview tokens (separate preview origin). */ previewTokens: PreviewTokenSigner; @@ -115,6 +119,8 @@ export interface BuildDepsOverrides { loader?: SessionLoader; /** Test double: Session title generator (avoids real LLM requests). */ titles?: TitleNotifier; + /** Test double: update-check service with a stubbed fetch/clock (avoids real network calls). */ + updateCheck?: UpdateCheckService; log?: (line: string) => void; now?: () => Date; } @@ -151,6 +157,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides (projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId), overrides.now ?? (() => new Date()), ); + const updateCheck = + overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {}); // Channel idle reclamation skips active Sessions (running/compacting can go a long time // without a publish, e.g. while waiting for approval). @@ -247,6 +255,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides sessionService, traceService, usageService, + updateCheck, workspaceFiles, previewTokens, benchmarks, @@ -331,6 +340,7 @@ export function createApp(deps: AppDeps): Hono { const auth = authMiddleware(deps.authService); app.use("/api/*", auth); app.route("/api/me", meRoutes(deps)); + app.route("/api/version", versionRoutes(deps)); app.route("/api/admin/users", adminUsersRoutes(deps)); app.route("/api/events", eventsRoutes(deps)); // Skill library listing: readable once logged in, not nested under a Project prefix. diff --git a/packages/server/src/http/routes/version.ts b/packages/server/src/http/routes/version.ts new file mode 100644 index 0000000..85deabe --- /dev/null +++ b/packages/server/src/http/routes/version.ts @@ -0,0 +1,154 @@ +/** + * Version routes: the running release identity, the update-check reminder, and the + * admin-only self-update. + * + * GET /api/version -> {version, buildDate} from core's VERSION / BUILD_DATE + * GET /api/version/update-check -> UpdateCheckService (fail-soft, cached, opt-out env; + * ?force=1 bypasses the cache for the manual check) + * POST /api/version/update -> admin only: re-runs the CLI as `penguin update --yes` + * + * How the self-update works: `penguin server|web` exports PENGUIN_CLI_ENTRY (its own entry + * script path) before importing this server, and this route re-runs that script as + * `node update --yes` — the CLI's update command owns all install-kind detection, + * download and replacement logic (packages/cli/src/commands/update.ts). A server started + * any other way (tests, a custom embedding) has no CLI to run and reports "unsupported". + * SECURITY: the spawned argv is a fixed literal list; nothing from the request flows into + * the command, its arguments, or its environment. + * + * Interpreting the CLI's outcome: `penguin update` exits non-zero only when an upgrade was + * attempted and failed; refusals (source checkout, unrecognized layout, Windows) print a + * message and exit 0. Exit codes alone therefore cannot separate "updated" from + * "unsupported", so the spawn forces PENGUIN_LANG=en and classifies by the refusal + * messages' stable English fragments (packages/cli/src/i18n.ts, `update` section). That + * matching is reliable here because the spawned CLI and this server ship in lockstep from + * the same install — the strings can never be from a different release than this code. + */ +import { spawn } from "node:child_process"; +import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core"; +import { Hono } from "hono"; +import type { UpdateRunResponse, VersionResponse } from "../../api/types.js"; +import { HttpError } from "../errors.js"; +import type { AppEnv } from "../../auth/middleware.js"; +import type { AppDeps } from "../../app.js"; + +/** Only the tail of the update output is kept (installer logs can be long). */ +const OUTPUT_CAP = 64 * 1024; +/** The whole update (download + install) must finish within this window. */ +const UPDATE_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * English fragments of the CLI refusal messages (packages/cli/src/i18n.ts `update.*`), + * one per refusal reason. `needsYes` / `cancelled` are omitted: `--yes` makes the + * confirmation gate proceed unconditionally, so neither can occur here. + */ +const REFUSAL_MARKERS = [ + "runs from a source checkout", // sourceCheckout + "Cannot tell how this penguin was installed", // unknownInstall + "could not be identified", // npmUnknownManager + "does not run on Windows", // windowsUnsupported + "cannot run your package manager for you", // windowsGlobalInstall +]; + +/** + * Maps the finished CLI run to an UpdateRunResponse. Exported for unit tests (tests never + * spawn anything real). Exit 0 with a refusal message is "unsupported"; any other exit 0 + * is "updated" — including the CLI's "already on the latest version", because that means + * the *install* is current and only a restart of this (older, in-memory) process is + * missing, which is exactly what needsRestart says. + */ +export function classifyUpdateRun(exitCode: number, output: string): UpdateRunResponse { + if (exitCode !== 0) return { status: "failed", output, needsRestart: false }; + if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) { + return { status: "unsupported", output, needsRestart: false }; + } + return { status: "updated", output, needsRestart: true }; +} + +/** Runs `node update --yes` to completion and classifies the outcome. */ +function runSelfUpdate(cliEntry: string): Promise { + return new Promise((resolve) => { + // PENGUIN_LANG=en pins the CLI's output language so classifyUpdateRun's markers match + // regardless of the deployment's configured CLI language. + const child = spawn(process.execPath, [cliEntry, "update", "--yes"], { + env: { ...process.env, PENGUIN_LANG: "en" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + const append = (chunk: Buffer) => { + output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP); + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + // A SIGTERM-ignoring child would otherwise leave this request hanging forever. + setTimeout(() => child.kill("SIGKILL"), 10_000).unref(); + }, UPDATE_TIMEOUT_MS); + timer.unref(); + + child.on("error", (err) => { + clearTimeout(timer); + resolve({ + status: "failed", + output: `${output}\n[failed to run the update command: ${err.message}]`.trim(), + needsRestart: false, + }); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (timedOut) { + resolve({ + status: "failed", + output: + `${output}\n[the update run was killed after ${UPDATE_TIMEOUT_MS / 60_000} minutes]`.trim(), + needsRestart: false, + }); + return; + } + resolve(classifyUpdateRun(code ?? -1, output.trim())); + }); + }); +} + +export function versionRoutes(deps: AppDeps): Hono { + const app = new Hono(); + + // Two admins clicking "update" at once must not race two installers over the same + // install dir: concurrent requests share the one in-flight run (and its result). + let inflightRun: Promise | null = null; + + app.get("/", (c) => { + return c.json({ version: VERSION, buildDate: BUILD_DATE } satisfies VersionResponse); + }); + + app.get("/update-check", async (c) => { + // ?force=1 (the web's manual "check for updates" action) bypasses the TTL cache; + // see UpdateCheckService.check for why a user-initiated press-through is acceptable. + return c.json(await deps.updateCheck.check(c.req.query("force") === "1")); + }); + + app.post("/update", async (c) => { + if (!c.var.user.isAdmin) { + throw new HttpError(403, "admin_required", "Only an admin can perform this operation."); + } + const cliEntry = process.env.PENGUIN_CLI_ENTRY; + if (!cliEntry) { + return c.json({ + status: "unsupported", + reason: "not_launched_via_cli", + output: "", + needsRestart: false, + } satisfies UpdateRunResponse); + } + inflightRun ??= runSelfUpdate(cliEntry).finally(() => { + inflightRun = null; + }); + return c.json(await inflightRun); + }); + + return app; +} diff --git a/packages/server/src/services/update-check-service.ts b/packages/server/src/services/update-check-service.ts new file mode 100644 index 0000000..aea3b42 --- /dev/null +++ b/packages/server/src/services/update-check-service.ts @@ -0,0 +1,141 @@ +/** + * Update-check service: resolves the newest published release from the GitHub Releases + * API and compares it with the running version (core's VERSION), for the web UI's + * update reminder (GET /api/version/update-check). + * + * This is the server's only outbound internet call, so it is strictly fail-soft: check() + * never throws and never surfaces an upstream failure as a 5xx — a failed lookup returns + * a normal response with `error` set and `latestVersion` null. Outcomes are cached + * in-memory (success for 1 hour, failure for 10 minutes) so a sidebar that opens on every + * page load cannot hammer GitHub's unauthenticated rate limit; concurrent requests share + * one in-flight lookup. `PENGUIN_UPDATE_CHECK=off` disables the lookup entirely (no + * network call), for air-gapped or privacy-sensitive deployments. + * + * The request mirrors the CLI's fetchLatestVersion (packages/cli/src/commands/update.ts): + * same endpoint, same accept / user-agent header shape, same 15s timeout — the server + * cannot import the CLI package (the dependency runs the other way), hence the small + * duplication. fetch and the clock are injectable so tests never touch the network. + */ +import { BUILD_DATE, VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core"; +import type { UpdateCheckResponse } from "../api/types.js"; + +/** Repository the released artifacts come from (same slug as cli/update.ts's REPO_SLUG). */ +const REPO_SLUG = "Prism-Shadow/penguin-harness"; +/** Releases API endpoint for the newest published release. */ +export const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`; + +/** How long a successful lookup is served from cache. */ +export const SUCCESS_TTL_MS = 60 * 60 * 1000; +/** How long a failed lookup is served from cache (short: transient failures should heal). */ +export const FAILURE_TTL_MS = 10 * 60 * 1000; + +export interface UpdateCheckServiceOptions { + /** Test double for the network call (defaults to global fetch). */ + fetchImpl?: typeof fetch; + /** Injectable clock for TTL determinism in tests. */ + now?: () => Date; + /** Environment to read PENGUIN_UPDATE_CHECK from (defaults to process.env). */ + env?: Record; +} + +export class UpdateCheckService { + private readonly fetchImpl: typeof fetch; + private readonly now: () => Date; + private readonly env: Record; + private cached: { response: UpdateCheckResponse; expiresAt: number } | null = null; + private inflight: Promise | null = null; + + constructor(options: UpdateCheckServiceOptions = {}) { + this.fetchImpl = options.fetchImpl ?? fetch; + this.now = options.now ?? (() => new Date()); + this.env = options.env ?? process.env; + } + + /** + * Never throws; never makes a network call when disabled or (without `force`) while + * the cache is fresh. `force` — the web's manual "check for updates" action — skips + * the freshness check and performs a real lookup even over a warm cache: the cache + * exists to shield GitHub's unauthenticated rate limit from *passive* checks fired + * by every sidebar open, and an explicit user click is rare enough to press through + * it. Everything else is unchanged: the opt-out stays authoritative (force never + * dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache + * (subsequent passive checks reuse it), and concurrent callers — forced or not — + * still share one in-flight lookup. + */ + async check(force = false): Promise { + if (this.env["PENGUIN_UPDATE_CHECK"] === "off") { + return { + currentVersion: VERSION, + buildDate: BUILD_DATE, + latestVersion: null, + updateAvailable: false, + releaseUrl: null, + publishedAt: null, + checkedAt: this.now().toISOString(), + disabled: true, + }; + } + if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) { + return this.cached.response; + } + this.inflight ??= this.lookup().finally(() => { + this.inflight = null; + }); + return this.inflight; + } + + /** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */ + private async lookup(): Promise { + const response = await this.resolveLatest(); + const ttl = response.error === undefined ? SUCCESS_TTL_MS : FAILURE_TTL_MS; + this.cached = { response, expiresAt: this.now().getTime() + ttl }; + return response; + } + + /** + * Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or + * timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit + * GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body + * without a usable tag_name is "bad_response". + */ + private async resolveLatest(): Promise { + const checkedAt = this.now().toISOString(); + const base = { currentVersion: VERSION, buildDate: BUILD_DATE, checkedAt }; + const failure = (error: "network" | "rate_limited" | "bad_response"): UpdateCheckResponse => ({ + ...base, + latestVersion: null, + updateAvailable: false, + releaseUrl: null, + publishedAt: null, + error, + }); + + let res: Response; + try { + res = await this.fetchImpl(LATEST_RELEASE_API, { + headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" }, + signal: AbortSignal.timeout(15_000), + }); + } catch { + return failure("network"); + } + if (res.status === 403 || res.status === 429) return failure("rate_limited"); + if (!res.ok) return failure("bad_response"); + + let body: { tag_name?: unknown; html_url?: unknown; published_at?: unknown }; + try { + body = (await res.json()) as typeof body; + } catch { + return failure("bad_response"); + } + const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : ""; + if (tag === "") return failure("bad_response"); + return { + ...base, + latestVersion: tag, + updateAvailable: compareVersions(tag, VERSION) > 0, + releaseUrl: typeof body.html_url === "string" ? body.html_url : null, + publishedAt: typeof body.published_at === "string" ? body.published_at : null, + }; + } +} diff --git a/packages/server/test/version.test.ts b/packages/server/test/version.test.ts new file mode 100644 index 0000000..d43687f --- /dev/null +++ b/packages/server/test/version.test.ts @@ -0,0 +1,298 @@ +/** + * Version routes and update-check service tests: response shapes, the fail-soft + * contract of /api/version/update-check (always 200, error field instead of 5xx), + * cache TTLs with an injected clock, the PENGUIN_UPDATE_CHECK=off opt-out, and the + * admin gate of POST /api/version/update. Nothing here touches the network or spawns + * a process: fetch is stubbed, and the update run is exercised only through its pure + * classifier and the "not launched via the CLI" early exit. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { VERSION } from "@prismshadow/penguin-core"; +import type { UpdateCheckResponse, UpdateRunResponse, VersionResponse } from "../src/api/types.js"; +import { + FAILURE_TTL_MS, + SUCCESS_TTL_MS, + UpdateCheckService, +} from "../src/services/update-check-service.js"; +import { classifyUpdateRun } from "../src/http/routes/version.js"; +import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +/** Counting fetch stub; the handler decides the outcome per call. */ +function makeFetch(handler: () => Response | Promise): { + impl: typeof fetch; + state: { calls: number }; +} { + const state = { calls: 0 }; + const impl: typeof fetch = async () => { + state.calls += 1; + return handler(); + }; + return { impl, state }; +} + +function releaseResponse(tag: string): Response { + return new Response( + JSON.stringify({ + tag_name: tag, + html_url: `https://github.com/Prism-Shadow/penguin-harness/releases/tag/${tag}`, + published_at: "2026-07-01T00:00:00Z", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +describe("GET /api/version", () => { + let t: TestApp; + beforeEach(async () => { + t = await createTestApp(); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("requires auth and reports core's version identity (dev build: buildDate null)", async () => { + expect((await t.app.request("/api/version")).status).toBe(401); + + const admin = await loginAdmin(t.app); + const res = await apiClient(t.app, admin.cookie).get("/api/version"); + expect(res.status).toBe(200); + const body = (await res.json()) as VersionResponse; + expect(body).toEqual({ version: VERSION, buildDate: null }); + }); +}); + +describe("GET /api/version/update-check", () => { + let t: TestApp; + afterEach(async () => { + await t.cleanup(); + }); + + it("reports a newer release with its URL and publish date", async () => { + const { impl } = makeFetch(() => releaseResponse("v99.0.0")); + t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) }); + const admin = await loginAdmin(t.app); + const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check"); + expect(res.status).toBe(200); + const body = (await res.json()) as UpdateCheckResponse; + expect(body.currentVersion).toBe(VERSION); + expect(body.latestVersion).toBe("99.0.0"); + expect(body.updateAvailable).toBe(true); + expect(body.releaseUrl).toBe( + "https://github.com/Prism-Shadow/penguin-harness/releases/tag/v99.0.0", + ); + expect(body.publishedAt).toBe("2026-07-01T00:00:00Z"); + expect(body.error).toBeUndefined(); + expect(body.disabled).toBeUndefined(); + }); + + it("fail-soft: an unreachable endpoint is HTTP 200 with error=network, not a 5xx", async () => { + const { impl } = makeFetch(() => { + throw new Error("getaddrinfo ENOTFOUND api.github.com"); + }); + t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) }); + const admin = await loginAdmin(t.app); + const res = await apiClient(t.app, admin.cookie).get("/api/version/update-check"); + expect(res.status).toBe(200); + const body = (await res.json()) as UpdateCheckResponse; + expect(body.error).toBe("network"); + expect(body.latestVersion).toBeNull(); + expect(body.updateAvailable).toBe(false); + expect(body.releaseUrl).toBeNull(); + }); + + it("?force=1 (the manual check) reaches the service as a cache bypass", async () => { + const { impl, state } = makeFetch(() => releaseResponse("v99.0.0")); + t = await createTestApp({ updateCheck: new UpdateCheckService({ fetchImpl: impl, env: {} }) }); + const admin = await loginAdmin(t.app); + const client = apiClient(t.app, admin.cookie); + + // Warm the cache, then confirm a plain GET serves from it. + expect((await client.get("/api/version/update-check")).status).toBe(200); + expect((await client.get("/api/version/update-check")).status).toBe(200); + expect(state.calls).toBe(1); + + const forced = await client.get("/api/version/update-check?force=1"); + expect(forced.status).toBe(200); + expect(((await forced.json()) as UpdateCheckResponse).latestVersion).toBe("99.0.0"); + expect(state.calls).toBe(2); + }); +}); + +describe("UpdateCheckService", () => { + it("maps 403/429 to rate_limited and other bad statuses/bodies to bad_response", async () => { + for (const [make, expected] of [ + [() => new Response("limited", { status: 403 }), "rate_limited"], + [() => new Response("limited", { status: 429 }), "rate_limited"], + [() => new Response("oops", { status: 500 }), "bad_response"], + [() => new Response("not json", { status: 200 }), "bad_response"], + [() => new Response(JSON.stringify({ name: "no tag" }), { status: 200 }), "bad_response"], + ] as const) { + const service = new UpdateCheckService({ fetchImpl: makeFetch(make).impl, env: {} }); + const result = await service.check(); + expect(result.error).toBe(expected); + expect(result.updateAvailable).toBe(false); + expect(result.latestVersion).toBeNull(); + } + }); + + it("updateAvailable is false when the latest release is not newer", async () => { + const { impl } = makeFetch(() => releaseResponse(`v${VERSION}`)); + const service = new UpdateCheckService({ fetchImpl: impl, env: {} }); + const result = await service.check(); + expect(result.error).toBeUndefined(); + expect(result.latestVersion).toBe(VERSION); + expect(result.updateAvailable).toBe(false); + }); + + it("PENGUIN_UPDATE_CHECK=off disables the lookup without any network call", async () => { + const { impl, state } = makeFetch(() => releaseResponse("v99.0.0")); + const service = new UpdateCheckService({ + fetchImpl: impl, + env: { PENGUIN_UPDATE_CHECK: "off" }, + }); + const result = await service.check(); + expect(result.disabled).toBe(true); + expect(result.updateAvailable).toBe(false); + expect(result.latestVersion).toBeNull(); + expect(state.calls).toBe(0); + }); + + it("caches a success for an hour and a failure for ten minutes", async () => { + let nowMs = 1_000_000_000; + let fail = false; + const { impl, state } = makeFetch(() => { + if (fail) throw new Error("down"); + return releaseResponse("v99.0.0"); + }); + const service = new UpdateCheckService({ + fetchImpl: impl, + env: {}, + now: () => new Date(nowMs), + }); + + const first = await service.check(); + expect(first.latestVersion).toBe("99.0.0"); + expect(state.calls).toBe(1); + + // Within the success TTL: served from cache, original checkedAt preserved. + nowMs += SUCCESS_TTL_MS - 1; + const cached = await service.check(); + expect(state.calls).toBe(1); + expect(cached.checkedAt).toBe(first.checkedAt); + + // Past the success TTL: refetched; the failure is itself cached, but only briefly. + nowMs += 2; + fail = true; + const failed = await service.check(); + expect(state.calls).toBe(2); + expect(failed.error).toBe("network"); + + nowMs += FAILURE_TTL_MS - 1; + expect((await service.check()).error).toBe("network"); + expect(state.calls).toBe(2); + + nowMs += 2; + fail = false; + const healed = await service.check(); + expect(state.calls).toBe(3); + expect(healed.error).toBeUndefined(); + expect(healed.latestVersion).toBe("99.0.0"); + }); + + it("force bypasses a warm cache and recaches the fresh outcome", async () => { + let tag = "v99.0.0"; + const { impl, state } = makeFetch(() => releaseResponse(tag)); + const service = new UpdateCheckService({ fetchImpl: impl, env: {} }); + + // Warm the cache; a passive check is then served from it. + expect((await service.check()).latestVersion).toBe("99.0.0"); + expect((await service.check()).latestVersion).toBe("99.0.0"); + expect(state.calls).toBe(1); + + // A forced check refetches despite the fresh cache… + tag = "v100.0.0"; + expect((await service.check(true)).latestVersion).toBe("100.0.0"); + expect(state.calls).toBe(2); + + // …and stores the outcome: the next passive check reuses it without a call. + expect((await service.check()).latestVersion).toBe("100.0.0"); + expect(state.calls).toBe(2); + }); + + it("force never dials out when PENGUIN_UPDATE_CHECK=off (the opt-out stays authoritative)", async () => { + const { impl, state } = makeFetch(() => releaseResponse("v99.0.0")); + const service = new UpdateCheckService({ + fetchImpl: impl, + env: { PENGUIN_UPDATE_CHECK: "off" }, + }); + const result = await service.check(true); + expect(result.disabled).toBe(true); + expect(state.calls).toBe(0); + }); +}); + +describe("POST /api/version/update", () => { + let t: TestApp; + let savedEntry: string | undefined; + beforeEach(async () => { + savedEntry = process.env.PENGUIN_CLI_ENTRY; + delete process.env.PENGUIN_CLI_ENTRY; + t = await createTestApp(); + }); + afterEach(async () => { + if (savedEntry === undefined) delete process.env.PENGUIN_CLI_ENTRY; + else process.env.PENGUIN_CLI_ENTRY = savedEntry; + await t.cleanup(); + }); + + it("is admin-only", async () => { + const user = await provisionUser(t.app, "regular_user"); + const res = await apiClient(t.app, user.cookie).post("/api/version/update", {}); + expect(res.status).toBe(403); + }); + + it("reports unsupported when the server was not launched via the CLI", async () => { + const admin = await loginAdmin(t.app); + const res = await apiClient(t.app, admin.cookie).post("/api/version/update", {}); + expect(res.status).toBe(200); + const body = (await res.json()) as UpdateRunResponse; + expect(body).toEqual({ + status: "unsupported", + reason: "not_launched_via_cli", + output: "", + needsRestart: false, + }); + }); +}); + +describe("classifyUpdateRun", () => { + it("a non-zero exit is a failed upgrade attempt", () => { + const r = classifyUpdateRun(1, "Upgrade failed; the previous install was left in place."); + expect(r.status).toBe("failed"); + expect(r.needsRestart).toBe(false); + }); + + it("a refusal (exit 0 with the CLI's refusal message) is unsupported", () => { + // Literal CLI copy (packages/cli/src/i18n.ts update.sourceCheckout / unknownInstall): + // the classifier matches fragments of these exact strings. + const source = + "This penguin runs from a source checkout, so there is nothing to download — update it with `git pull` and rebuild (`pnpm install && pnpm -r build`)."; + expect(classifyUpdateRun(0, source).status).toBe("unsupported"); + const unknown = + "Cannot tell how this penguin was installed (running from /opt/penguin/cli.js), so it will not be replaced. Re-install with the official installer, or upgrade with the package manager you used."; + expect(classifyUpdateRun(0, unknown).status).toBe("unsupported"); + }); + + it("a clean exit without a refusal is updated and needs a restart (up-to-date included)", () => { + const done = + "Upgrade 0.1.2 -> 0.1.3\nPenguinHarness 0.1.3 installed. Run `penguin --version` in a new shell to confirm."; + expect(classifyUpdateRun(0, done)).toEqual({ + status: "updated", + output: done, + needsRestart: true, + }); + const upToDate = "Already on the latest version (0.1.3); nothing to do."; + expect(classifyUpdateRun(0, upToDate).status).toBe("updated"); + }); +}); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 4053a5b..3912b19 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -60,10 +60,13 @@ import type { TraceImportRequest, TraceImportResponse, UiPrefs, + UpdateCheckResponse, + UpdateRunResponse, UsageGroupBy, UsageResponse, VaultResponse, VaultUpdateRequest, + VersionResponse, WorkspaceFilesResponse, } from "@prismshadow/penguin-server/api"; import { apiFetch } from "./client"; @@ -496,3 +499,15 @@ export const importAgent = (projectId: string, agentId: string, body: AgentImpor `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/import`, { method: "POST", body }, ); + +// Version & self-update ---------------------------------------------------------------- + +export const getVersion = () => apiFetch("/api/version"); + +/** `force` (the manual "check for updates" action) bypasses the server's TTL cache. */ +export const checkUpdate = (force = false) => + apiFetch(`/api/version/update-check${force ? "?force=1" : ""}`); + +/** Admin only: runs `penguin update` on the server host (long request — up to 10 minutes). */ +export const runUpdate = () => + apiFetch("/api/version/update", { method: "POST", body: {} }); diff --git a/packages/web/src/components/account/update-dialog.tsx b/packages/web/src/components/account/update-dialog.tsx new file mode 100644 index 0000000..5503503 --- /dev/null +++ b/packages/web/src/components/account/update-dialog.tsx @@ -0,0 +1,103 @@ +/** + * Admin self-update dialog (opened from the sidebar user menu's update reminder): + * explains that the new release is downloaded into the install directory and that the + * service must be restarted afterwards, then runs POST /api/version/update and shows the + * outcome — the CLI's own output tail in a scrollable
 for the failed/unsupported
+ * states, and a restart hint on success. Closing is blocked while the update runs (the
+ * request can take minutes; navigating away would just hide the result).
+ */
+import { useEffect, useState } from "react";
+import type { UpdateRunResponse } from "@prismshadow/penguin-server/api";
+import * as api from "../../api/endpoints";
+import { S } from "../../lib/strings";
+import { apiErrorText } from "../../lib/api-error";
+import { Button } from "../ui/button";
+import { Modal } from "../ui/modal";
+
+type Phase = "confirm" | "running" | "done";
+
+export function UpdateDialog({
+  open,
+  onClose,
+  latestVersion,
+}: {
+  open: boolean;
+  onClose: () => void;
+  latestVersion: string | null;
+}) {
+  const [phase, setPhase] = useState("confirm");
+  const [result, setResult] = useState(null);
+
+  useEffect(() => {
+    if (!open) return;
+    setPhase("confirm");
+    setResult(null);
+  }, [open]);
+
+  const run = async () => {
+    setPhase("running");
+    try {
+      setResult(await api.runUpdate());
+    } catch (e) {
+      // The request itself failed (e.g. the connection dropped mid-run): surface it the
+      // same way as a failed run, with the error text where the output tail would be.
+      setResult({ status: "failed", output: apiErrorText(e), needsRestart: false });
+    }
+    setPhase("done");
+  };
+
+  const statusLine =
+    result === null
+      ? null
+      : result.status === "updated"
+        ? { text: S.update.updated, className: "text-green-600 dark:text-green-400" }
+        : result.status === "unsupported"
+          ? { text: S.update.unsupported, className: "text-amber-600 dark:text-amber-400" }
+          : { text: S.update.failed, className: "text-red-600 dark:text-red-400" };
+
+  return (
+     undefined : onClose}
+      footer={
+        phase === "done" ? (
+          
+        ) : (
+          <>
+            
+            
+          
+        )
+      }
+    >
+      {phase === "done" && result !== null && statusLine !== null ? (
+        
+

{statusLine.text}

+ {result.status === "updated" && ( +

{S.update.restartHint}

+ )} + {result.output !== "" && ( +
+              {result.output}
+            
+ )} +
+ ) : ( +
+ {latestVersion !== null && ( +

{S.update.newVersion(latestVersion)}

+ )} +

{S.update.confirmBody}

+ {phase === "running" && ( +

{S.update.updating}

+ )} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index b468aeb..be5a754 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -25,6 +25,7 @@ import type { } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { S } from "../../lib/strings"; +import { formatMonthDay } from "../../lib/format"; import { apiErrorText } from "../../lib/api-error"; import { useAuth } from "../../state/auth"; import { useLocale } from "../../state/locale"; @@ -48,7 +49,7 @@ import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; import { Chevron } from "../ui/chevron"; import { ChevronDown } from "../ui/icons"; -import { toastError } from "../ui/toast"; +import { toastError, toastInfo, toastSuccess } from "../ui/toast"; import { Truncated } from "../ui/truncated"; import { Badge } from "../ui/badge"; import { Modal } from "../ui/modal"; @@ -61,6 +62,8 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page"; import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache"; import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs"; import { ChangePasswordDialog } from "../account/change-password-dialog"; +import { UpdateDialog } from "../account/update-dialog"; +import { forceUpdateCheck, useVersionInfo } from "../../lib/use-version-info"; function Icon({ d, size = 16 }: { d: string; size?: number }) { return ( @@ -114,6 +117,14 @@ const PIN_ICON = const menuItemClass = "block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"; +/** + * Superscript "new version" pill on the version line (accent-colored, raised via + * align-super). Kept literally identical to the draft page's copy in + * features/chat/draft-view.tsx — the two surfaces must not drift apart. + */ +const versionBadgeClass = + "ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80"; + /** Grouping mode of the Session list (persisted; Workspace is the default). */ type GroupMode = "workspace" | "agent"; const GROUP_MODE_KEY = "penguin.sidebarGroupMode"; @@ -190,7 +201,7 @@ export function Sidebar({ const { user, logout } = useAuth(); const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } = useTheme(); - const { lang, setLang } = useLocale(); + const { lang, locale, setLang } = useLocale(); const { projects, currentProject, @@ -220,6 +231,40 @@ export function Sidebar({ const [createProjectOpen, setCreateProjectOpen] = useState(false); const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + // Version row + update reminder: nothing is fetched until the dropdown first opens. + const { version, update } = useVersionInfo(userOpen); + const updateAvailable = update?.updateAvailable === true; + // The running version's release date, stamped into core's BUILD_DATE at build time by + // the release workflow — displayed as-is, no network involved. Dev builds and releases + // that predate the stamping (v0.1.2 and earlier) carry null. Shown as the localized + // "last updated" tooltip on the check-for-updates row (the row itself stays uncluttered). + const versionDate = version?.buildDate ?? null; + /** Manual "check for updates" in flight (row disabled, busy label). */ + const [updateChecking, setUpdateChecking] = useState(false); + /** + * Manual update check (owner request): forces a lookup past the server's TTL cache and + * pushes the result into the shared version-info store, so the reminder rows, badge, + * and dot appear immediately when a newer release is found — that visible change is + * the notification then. A toast fires only when nothing changes visibly (#54, one + * notification per action): up to date, checks disabled, or a failed lookup (the + * check is fail-soft — failure arrives as the `error` field, not an exception; the + * catch handles our own server being unreachable). + */ + const runUpdateCheck = async () => { + if (updateChecking) return; + setUpdateChecking(true); + try { + const res = await forceUpdateCheck(); + if (res.disabled === true) toastInfo(S.update.checkDisabled); + else if (res.error !== undefined) toastError(S.update.checkFailed); + else if (!res.updateAvailable) toastSuccess(S.update.upToDate); + } catch (e) { + toastError(apiErrorText(e)); + } finally { + setUpdateChecking(false); + } + }; const currentProjectId = currentProject?.projectId ?? null; const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId); const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId); @@ -958,8 +1003,17 @@ export function Sidebar({ onClick={() => setUserOpen(!userOpen)} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800" > - + {(user?.userId ?? "?").slice(0, 1).toUpperCase()} + {/* Update reminder dot: only once the lazy check has actually run and found a + newer release. The border (sidebar background color) separates it from the + avatar for every accent — the neutral accent matches the avatar fill. */} + {updateAvailable && ( + + )} {user?.userId} {user?.isAdmin && ( @@ -990,6 +1044,41 @@ export function Sidebar({ + {/* Update reminder: release-notes link, plus the self-update action for admins. + Only rendered after the lazy check found a newer release. */} + {update !== null && update.updateAvailable && update.latestVersion !== null && ( +
+ {update.releaseUrl !== null ? ( + + + {S.update.newVersion(update.latestVersion)} + + ) : ( + + + {S.update.newVersion(update.latestVersion)} + + )} + {user?.isAdmin && ( + + )} +
+ )}
+ {/* Manual update check, directly below Change password (owner layout). The + running version sits muted on the right of the same row — no product-name + prefix — and the superscript new-version badge rides along there as a + passive indicator: a nested button/link inside this button row would be + invalid HTML, and whenever the badge shows, the clickable affordances + (release link / Update now) are already present in the reminder rows + above. The "last updated" date lives in the row tooltip, keeping the row + itself uncluttered. While checking, the label swaps to the busy text and + the right-side version stays put. Nothing is fetched until the menu first + opens; the version span appears once /api/version resolves. */} + {/* User management is visible only to admins (the page route also has its own guard as a fallback). */} {user?.isAdmin && (
+ {`PenguinHarness v${version.version}${ + date !== null ? ` · ${S.update.lastUpdated(formatMonthDay(date, locale))}` : "" + }`} + {update !== null && + update.updateAvailable && + update.latestVersion !== null && + (update.releaseUrl !== null ? ( + + {S.update.newVersionBadge} + + ) : ( + {S.update.newVersionBadge} + ))} +

+ ); +} + /** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */ const pillClass = "flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " + diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index 7e7814d..a520e65 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -136,6 +136,42 @@ export function formatDateTime(iso: string): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } +/** English month abbreviations for formatMonthDay (Intl-free — see the rationale there). */ +const EN_MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +/** + * `yyyy-mm-dd` (or a full ISO timestamp — only the date part is read) → localized + * month + day, no year: en `Jul 26`, zh `7 月 26 日` (the version footer's "last + * updated" date, product-specified wording — the zh form keeps the CJK/numeral + * spacing the owner asked for, which `Intl` would drop). The fields are read + * straight from the string rather than via `new Date()` + local-zone formatting: + * the input is a UTC calendar date (core's stamped BUILD_DATE, a release's + * publish timestamp), and round-tripping it through the viewer's timezone would + * render the previous day west of UTC. Unparsable or out-of-range input returns + * unchanged (same convention as formatDateTime). + */ +export function formatMonthDay(iso: string, locale: "zh" | "en"): string { + const m = /^\d{4}-(\d{2})-(\d{2})(?:$|T)/.exec(iso); + if (!m) return iso; + const month = Number(m[1]); + const day = Number(m[2]); + if (month < 1 || month > 12 || day < 1 || day > 31) return iso; + return locale === "en" ? `${EN_MONTHS[month - 1]} ${day}` : `${month} 月 ${day} 日`; +} + /** * Millisecond timestamp → human-readable message time: en `Jul 2, 2:58 PM` / * zh `7月2日 14:58`; returns an empty string for an invalid value. diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 42e8546..bdd74bd 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -54,6 +54,30 @@ export const en: Strings = { } as Record, }, + /** Version footer, update reminder, and admin self-update in the sidebar user menu. */ + update: { + /** Version-line date label; `date` is formatMonthDay output, e.g. "Last updated Jul 26". */ + lastUpdated: (date: string) => `Last updated ${date}`, + /** Superscript badge on the version lines when the update check found a newer release. */ + newVersionBadge: "New version available", + newVersion: (v: string) => `New version v${v} available`, + /** Manual check action in the sidebar user menu, with its busy label and toast outcomes. */ + checkNow: "Check for updates", + checking: "Checking…", + upToDate: "You're on the latest version", + checkFailed: "Update check failed — try again later", + checkDisabled: "Update checks are disabled (PENGUIN_UPDATE_CHECK=off)", + releaseNotes: "Release notes", + updateNow: "Update now", + updating: "Updating…", + updated: "Update complete — restart the service to apply", + restartHint: "Restart by re-running penguin web (or penguin server) in a terminal", + failed: "Update failed", + unsupported: "This install cannot be updated from the web UI", + confirmBody: + "Downloads the latest release and installs it into the install directory on the server (the data directory is not touched). Restart the service afterwards for the update to take effect.", + }, + common: { save: "Save", cancel: "Cancel", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index e2c2821..cb7d974 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -55,6 +55,30 @@ export const zh = { } as Record, }, + /** Version footer, update reminder, and admin self-update in the sidebar user menu. */ + update: { + /** Version-line date label (owner-specified wording); `date` is formatMonthDay output, e.g. 「最近更新日期 7 月 26 日」. */ + lastUpdated: (date: string) => `最近更新日期 ${date}`, + /** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */ + newVersionBadge: "有新版本可用", + newVersion: (v: string) => `新版本 v${v} 可用`, + /** Manual check action in the sidebar user menu (owner request), with its busy label and toast outcomes. */ + checkNow: "检查更新", + checking: "检查中…", + upToDate: "已是最新版本", + checkFailed: "检查更新失败,请稍后重试", + checkDisabled: "更新检查已关闭(PENGUIN_UPDATE_CHECK=off)", + releaseNotes: "更新说明", + updateNow: "立即更新", + updating: "更新中…", + updated: "更新完成,重启服务后生效", + restartHint: "在终端重新运行 penguin web(或 penguin server)即可完成重启", + failed: "更新失败", + unsupported: "当前安装方式不支持在线更新", + confirmBody: + "将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。", + }, + common: { save: "保存", cancel: "取消", diff --git a/packages/web/src/lib/use-version-info.ts b/packages/web/src/lib/use-version-info.ts new file mode 100644 index 0000000..8b1ca0f --- /dev/null +++ b/packages/web/src/lib/use-version-info.ts @@ -0,0 +1,113 @@ +/** + * Lazily fetched version identity + update check for the sidebar user menu. + * + * Neither request fires on app load: both start on the first activation (the first time + * the user opens the bottom dropdown) and the results are cached at module level for the + * rest of the browser session — a locale switch remounts the whole tree, and component + * state would refetch on every remount. Failures resolve to null and clear the shared + * promise so a later dropdown open retries; nothing is surfaced as an error (the footer + * simply shows nothing, and "no update known" hides the reminder). forceUpdateCheck is + * the one deliberate exception to the laziness: the user asked, so it refetches now and + * broadcasts the result to every mounted hook. + */ +import { useEffect, useState } from "react"; +import type { UpdateCheckResponse, VersionResponse } from "@prismshadow/penguin-server/api"; +import * as api from "../api/endpoints"; + +let versionCache: VersionResponse | null = null; +let versionPromise: Promise | null = null; +let updateCache: UpdateCheckResponse | null = null; +let updatePromise: Promise | null = null; + +/** + * Mounted hooks subscribe here so forceUpdateCheck (the sidebar's manual "check for + * updates" action) can push its fresh result to every consumer at once — the footer, + * the update dot, the reminder rows, and the draft page's version line all react + * without a remount. The lazy fetch path doesn't need this (each hook awaits the + * shared promise itself); only an out-of-band refresh does. + */ +const listeners = new Set<() => void>(); + +export interface VersionInfo { + version: VersionResponse | null; + update: UpdateCheckResponse | null; +} + +export function useVersionInfo(active: boolean): VersionInfo { + // Initial state comes from the module cache, so a remounted sidebar (locale switch) + // shows the version footer and the update dot immediately, without reopening anything. + const [version, setVersion] = useState(versionCache); + const [update, setUpdate] = useState(updateCache); + + // Re-sync from the module cache whenever forceUpdateCheck pushes a fresh result. + useEffect(() => { + const sync = () => { + setVersion(versionCache); + setUpdate(updateCache); + }; + listeners.add(sync); + return () => { + listeners.delete(sync); + }; + }, []); + + useEffect(() => { + if (!active) return; + let cancelled = false; + + versionPromise ??= api.getVersion().then((res) => { + versionCache = res; + return res; + }); + versionPromise + .then((res) => { + if (!cancelled) setVersion(res); + }) + .catch(() => { + versionPromise = null; + }); + + updatePromise ??= api.checkUpdate().then((res) => { + updateCache = res; + return res; + }); + updatePromise + .then((res) => { + if (!cancelled) setUpdate(res); + }) + .catch(() => { + updatePromise = null; + }); + + return () => { + cancelled = true; + }; + }, [active]); + + return { version, update }; +} + +/** + * Forced re-check for the sidebar's manual "check for updates" action: asks the server + * to bypass its TTL cache (?force=1), replaces the module cache, and pushes the result + * to every mounted consumer. The shared promise is swapped in up front so consumers + * activating mid-flight await the fresh lookup instead of resurrecting a stale one. + * The update check itself stays fail-soft (a lookup failure resolves normally with + * `error` set); this rejects only when the request to our own server fails — then the + * shared promise is cleared so the passive path can retry, and the caller toasts. + */ +export async function forceUpdateCheck(): Promise { + const promise = api.checkUpdate(true).then((res) => { + updateCache = res; + return res; + }); + updatePromise = promise; + try { + return await promise; + } catch (e) { + if (updatePromise === promise) updatePromise = null; + throw e; + } finally { + for (const notify of listeners) notify(); + } +} diff --git a/packages/web/test/format.test.ts b/packages/web/test/format.test.ts index 98bb964..72589df 100644 --- a/packages/web/test/format.test.ts +++ b/packages/web/test/format.test.ts @@ -8,6 +8,7 @@ import { formatBytes, formatDateTime, formatMoney, + formatMonthDay, formatPercent, formatRelativeDate, formatRelativeDays, @@ -226,3 +227,25 @@ describe("formatRelativeDate (semantic update time on Skill cards)", () => { expect(formatRelativeDate("", "en")).toBe(""); }); }); + +describe("formatMonthDay (version-line 'last updated' date)", () => { + it("formats a date-only string per locale, matching the owner-specified wording", () => { + expect(formatMonthDay("2026-07-26", "en")).toBe("Jul 26"); + expect(formatMonthDay("2026-07-26", "zh")).toBe("7 月 26 日"); + expect(formatMonthDay("2026-01-05", "en")).toBe("Jan 5"); + expect(formatMonthDay("2026-12-31", "zh")).toBe("12 月 31 日"); + }); + + it("reads only the date part of a full ISO timestamp — no timezone round-trip that could shift a day", () => { + expect(formatMonthDay("2026-07-01T00:00:00Z", "en")).toBe("Jul 1"); + expect(formatMonthDay("2026-05-05T12:00:00Z", "zh")).toBe("5 月 5 日"); + }); + + it("returns unparsable or out-of-range input unchanged", () => { + expect(formatMonthDay("not-a-date", "en")).toBe("not-a-date"); + expect(formatMonthDay("2026-7-26", "zh")).toBe("2026-7-26"); // not the zero-padded wire format + expect(formatMonthDay("2026-07-26x", "en")).toBe("2026-07-26x"); + expect(formatMonthDay("2026-13-01", "en")).toBe("2026-13-01"); + expect(formatMonthDay("2026-00-10", "zh")).toBe("2026-00-10"); + }); +});