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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user