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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 <entry> 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 <entry> 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 };
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 与成员
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 的编辑以及各类删除操作。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AppEnv> {
|
||||
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.
|
||||
|
||||
@@ -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 <entry> 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 <cliEntry> update --yes` to completion and classifies the outcome. */
|
||||
function runSelfUpdate(cliEntry: string): Promise<UpdateRunResponse> {
|
||||
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<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// 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<UpdateRunResponse> | 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;
|
||||
}
|
||||
@@ -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<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class UpdateCheckService {
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly now: () => Date;
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
private cached: { response: UpdateCheckResponse; expiresAt: number } | null = null;
|
||||
private inflight: Promise<UpdateCheckResponse> | 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<UpdateCheckResponse> {
|
||||
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<UpdateCheckResponse> {
|
||||
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<UpdateCheckResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<Response>): {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<VersionResponse>("/api/version");
|
||||
|
||||
/** `force` (the manual "check for updates" action) bypasses the server's TTL cache. */
|
||||
export const checkUpdate = (force = false) =>
|
||||
apiFetch<UpdateCheckResponse>(`/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<UpdateRunResponse>("/api/version/update", { method: "POST", body: {} });
|
||||
|
||||
@@ -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 <pre> 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<Phase>("confirm");
|
||||
const [result, setResult] = useState<UpdateRunResponse | null>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
title={S.update.updateNow}
|
||||
onClose={phase === "running" ? () => undefined : onClose}
|
||||
footer={
|
||||
phase === "done" ? (
|
||||
<Button onClick={onClose}>{S.common.close}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose} disabled={phase === "running"}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={phase === "running"} onClick={() => void run()}>
|
||||
{phase === "running" ? S.update.updating : S.update.updateNow}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{phase === "done" && result !== null && statusLine !== null ? (
|
||||
<div className="space-y-3">
|
||||
<p className={`text-sm font-medium ${statusLine.className}`}>{statusLine.text}</p>
|
||||
{result.status === "updated" && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.restartHint}</p>
|
||||
)}
|
||||
{result.output !== "" && (
|
||||
<pre className="max-h-56 overflow-auto rounded-md bg-gray-100 p-3 text-xs leading-relaxed whitespace-pre-wrap text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{latestVersion !== null && (
|
||||
<p className="text-sm font-medium">{S.update.newVersion(latestVersion)}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{S.update.confirmBody}</p>
|
||||
{phase === "running" && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{S.update.updating}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
|
||||
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
|
||||
{(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 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-50 bg-[var(--accent-bg)] dark:border-gray-900"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{user?.userId}</span>
|
||||
{user?.isAdmin && (
|
||||
@@ -990,6 +1044,41 @@ export function Sidebar({
|
||||
<Segmented options={langOptions} value={lang} onChange={setLang} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||
{update.releaseUrl !== null ? (
|
||||
<a
|
||||
href={update.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={S.update.releaseNotes}
|
||||
className={`${menuItemClass} flex items-center gap-2 font-medium`}
|
||||
>
|
||||
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
|
||||
{S.update.newVersion(update.latestVersion)}
|
||||
</a>
|
||||
) : (
|
||||
<span className={`${menuItemClass} flex items-center gap-2 font-medium`}>
|
||||
<span aria-hidden className="h-2 w-2 rounded-full bg-[var(--accent-bg)]" />
|
||||
{S.update.newVersion(update.latestVersion)}
|
||||
</span>
|
||||
)}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
setUpdateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{S.update.updateNow}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1001,6 +1090,40 @@ export function Sidebar({
|
||||
>
|
||||
{S.account.changePassword}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateChecking}
|
||||
onClick={() => void runUpdateCheck()}
|
||||
{...(versionDate !== null
|
||||
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
|
||||
: {})}
|
||||
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span>{updateChecking ? S.update.checking : S.update.checkNow}</span>
|
||||
{version !== null && (
|
||||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||
{`v${version.version}`}
|
||||
{update !== null && update.updateAvailable && update.latestVersion !== null && (
|
||||
<span
|
||||
className={versionBadgeClass}
|
||||
title={S.update.newVersion(update.latestVersion)}
|
||||
>
|
||||
{S.update.newVersionBadge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
@@ -1032,6 +1155,11 @@ export function Sidebar({
|
||||
open={changePasswordOpen}
|
||||
onClose={() => setChangePasswordOpen(false)}
|
||||
/>
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onClose={() => setUpdateDialogOpen(false)}
|
||||
latestVersion={update?.latestVersion ?? null}
|
||||
/>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={createProjectOpen}
|
||||
|
||||
@@ -40,8 +40,10 @@ 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";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
@@ -49,6 +51,7 @@ import { Chevron } from "../../components/ui/chevron";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { useVersionInfo } from "../../lib/use-version-info";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { buildSkillsMessage } from "./skill-use";
|
||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||
@@ -510,6 +513,7 @@ export function DraftView({
|
||||
{S.appName}
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
|
||||
<VersionLine />
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
@@ -665,6 +669,57 @@ export function DraftView({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Superscript "new version" pill on the version line (accent-colored, raised via
|
||||
* align-super). Kept literally identical to the sidebar footer's copy in
|
||||
* components/layout/sidebar.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";
|
||||
|
||||
/**
|
||||
* Quiet version line under the brand subtitle: `PenguinHarness vX.Y.Z · 最近更新日期
|
||||
* 7 月 26 日` / `… · Last updated Jul 26`. The date is the running version's release
|
||||
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
|
||||
* dev builds and releases that predate the stamping (v0.1.2 and earlier) carry null
|
||||
* and show the version alone. When the update check knows a newer release, a small
|
||||
* superscript badge follows, linking to the release page (this surface's existing
|
||||
* affordance; the sidebar's badge additionally offers admins the update dialog).
|
||||
* Fetching starts on mount — useVersionInfo caches at module level, so after the first
|
||||
* resolution anywhere in the app this renders instantly and never refetches. Nothing
|
||||
* renders until the version resolves (no placeholder flicker under the brand).
|
||||
*/
|
||||
function VersionLine() {
|
||||
const { locale } = useLocale();
|
||||
const { version, update } = useVersionInfo(true);
|
||||
if (version === null) return null;
|
||||
const date = version.buildDate;
|
||||
return (
|
||||
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{`PenguinHarness v${version.version}${
|
||||
date !== null ? ` · ${S.update.lastUpdated(formatMonthDay(date, locale))}` : ""
|
||||
}`}
|
||||
{update !== null &&
|
||||
update.updateAvailable &&
|
||||
update.latestVersion !== null &&
|
||||
(update.releaseUrl !== null ? (
|
||||
<a
|
||||
href={update.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={S.update.newVersion(update.latestVersion)}
|
||||
aria-label={S.update.newVersion(update.latestVersion)}
|
||||
className={versionBadgeClass}
|
||||
>
|
||||
{S.update.newVersionBadge}
|
||||
</a>
|
||||
) : (
|
||||
<span className={versionBadgeClass}>{S.update.newVersionBadge}</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 " +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,30 @@ export const en: Strings = {
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
/** 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",
|
||||
|
||||
@@ -55,6 +55,30 @@ export const zh = {
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
||||
/** 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: "取消",
|
||||
|
||||
@@ -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<VersionResponse> | null = null;
|
||||
let updateCache: UpdateCheckResponse | null = null;
|
||||
let updatePromise: Promise<UpdateCheckResponse> | 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<VersionResponse | null>(versionCache);
|
||||
const [update, setUpdate] = useState<UpdateCheckResponse | null>(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<UpdateCheckResponse> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user