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:
Yaowei Zheng
2026-07-27 23:34:06 +08:00
committed by GitHub
parent 033d526f9f
commit e1141ca010
27 changed files with 1375 additions and 40 deletions
+24 -1
View File
@@ -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 };
}
+6 -36
View File
@@ -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: