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
+19
View File
@@ -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);
});
}
});