release: 0.1.1 with Gemini 3.6 support and two blog posts (#44)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@prismshadow/penguin-cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"description": "PenguinHarness CLI: interactive REPL and single-task runner over @prismshadow/penguin-core.",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* `penguin update` — upgrades an existing install in place.
|
||||
*
|
||||
* penguin update [--check] [--release <tag>] [-y|--yes]
|
||||
*
|
||||
* There is no single upgrade mechanism, because there is no single install mechanism: the
|
||||
* documented path is the tarball installer (install.sh unpacks bin/lib/web/node into
|
||||
* PENGUIN_INSTALL_DIR, default ~/.penguin), some users have a global npm install of
|
||||
* @prismshadow/penguin-cli, and developers run out of a source checkout. This command works out
|
||||
* which one it is from the real path of the running CLI and upgrades the way that install was
|
||||
* made — never by guessing. A source checkout is refused outright: overwriting a working tree
|
||||
* would destroy uncommitted work.
|
||||
*
|
||||
* The latest version comes from the GitHub Releases API, the same source of truth install.sh
|
||||
* resolves `releases/latest/download` against. The target-a-specific-release flag is spelled
|
||||
* `--release <tag>` rather than `--version <tag>`: commander's program-level `-v, --version`
|
||||
* intercepts a subcommand's own `--version` when it is written with a space, so
|
||||
* `penguin update --version 0.1.2` would silently print the CLI version and do nothing. A flag
|
||||
* that works only in its `--version=0.1.2` form is a trap, so it got an unambiguous name.
|
||||
*
|
||||
* Self-replacement hazard, and how it is handled: for a tarball install the installer deletes and
|
||||
* replaces `lib/`, which is the directory this very process is executing from. Two things make
|
||||
* that safe. (1) The upgrade runs in a child `sh`, from a script written to a private temporary
|
||||
* directory — not inside the tree being replaced — so the script itself is never pulled out from
|
||||
* under its own interpreter. (2) The parent does nothing after the swap begins that would touch
|
||||
* the replaced tree: every module it needs is statically imported at the top of the bundle and
|
||||
* therefore fully loaded before any action runs, every message it will print is resolved up front,
|
||||
* and after the child exits it only removes its own temp directory, writes already-computed
|
||||
* strings and sets an exit code. It never `import()`s anything (the CLI's only dynamic import is
|
||||
* `@prismshadow/penguin-server`, reachable solely from the serve commands), and never re-reads a
|
||||
* file. On POSIX an unlinked file stays valid for whoever has it open, so the running process is
|
||||
* unaffected.
|
||||
*
|
||||
* Where the installer script is written, and why it matters: `tmpdir()` is world-writable and
|
||||
* shared, so a fixed or guessable name there is a local privilege-escalation primitive — another
|
||||
* user can pre-create the path as a symlink and have this process overwrite a file it owns, or
|
||||
* swap the file between the write and the `spawn`, turning the upgrade into arbitrary code
|
||||
* execution as the invoking user. The script therefore goes into a fresh `mkdtempSync` directory:
|
||||
* created 0700 with an unpredictable name, so no one else can name the path in advance, and the
|
||||
* file is written with `wx` so an existing entry is an error rather than a silent truncation. The
|
||||
* directory is removed only after the child `sh` has exited (see the note at the call site).
|
||||
*
|
||||
* Windows never reaches either upgrade path. The tarball installer is a POSIX shell script; and a
|
||||
* global npm/pnpm/yarn/bun install cannot be driven from here either, because `spawn` without a
|
||||
* shell does no PATHEXT resolution and Node has refused to exec `.cmd` shims without one since the
|
||||
* CVE-2024-27980 fix. Rather than fall through to a generic failure — or spawn through `cmd.exe`,
|
||||
* which would interpolate a user-supplied release tag into a command line — both cases are refused
|
||||
* up front with the command the user should run themselves.
|
||||
*
|
||||
* The data root (~/.penguin/data) is never touched — the installer only replaces bin/lib/web/node
|
||||
* — and the confirmation prompt says so, because that is the thing users worry about.
|
||||
* Docs: /docs/cli § "penguin update".
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
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 type { Command } from "commander";
|
||||
import type { Messages } from "../i18n.js";
|
||||
|
||||
/** 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. */
|
||||
export const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
|
||||
|
||||
/** How this copy of the CLI was installed, which decides how it can be upgraded. */
|
||||
export type InstallKind = "tarball" | "npm" | "source" | "unknown";
|
||||
|
||||
export interface InstallInfo {
|
||||
kind: InstallKind;
|
||||
/** Tarball only: the install dir that holds bin/lib/web/node (i.e. PENGUIN_INSTALL_DIR). */
|
||||
installDir?: string;
|
||||
/** npm only: the global `node_modules` root that owns the package, used to identify the manager. */
|
||||
globalRoot?: string;
|
||||
}
|
||||
|
||||
/** Global-install package managers we can drive; `null` means "tell the user, do not guess". */
|
||||
export type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
|
||||
|
||||
/** Splits a path into segments on either separator, so the same logic works for win32 paths. */
|
||||
function segments(p: string): string[] {
|
||||
return p.split(/[\\/]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out how this CLI was installed from the resolved real path of its own module.
|
||||
*
|
||||
* Pure and shape-based on purpose: it touches neither the filesystem nor the environment beyond
|
||||
* what is passed in, so every branch is unit-testable. The three layouts are unambiguous:
|
||||
*
|
||||
* - source checkout — `…/packages/cli/{src,dist}/index.{ts,js}` (also how a `pnpm link`-ed dev
|
||||
* build looks once the bin symlink is resolved);
|
||||
* - npm global — the path runs through `node_modules/@prismshadow/penguin-cli/`;
|
||||
* - tarball — `<installDir>/lib/dist/index.js`, the layout install.sh unpacks.
|
||||
*
|
||||
* Order matters: a checkout is checked first so a repo that happens to live under a directory
|
||||
* called `lib` cannot be mistaken for an install.
|
||||
*/
|
||||
export function detectInstall(modulePath: string): InstallInfo {
|
||||
const parts = segments(modulePath);
|
||||
const sep = modulePath.includes("\\") && !modulePath.includes("/") ? "\\" : path.sep;
|
||||
const join = (upto: number) => {
|
||||
const joined = parts.slice(0, upto).join(sep);
|
||||
return modulePath.startsWith("/") ? `${sep}${joined}` : joined;
|
||||
};
|
||||
|
||||
// …/packages/cli/(src|dist)/index.(ts|js)
|
||||
const cliIdx = parts.findIndex(
|
||||
(seg, i) => seg === "packages" && parts[i + 1] === "cli" && i + 2 < parts.length,
|
||||
);
|
||||
if (cliIdx >= 0 && (parts[cliIdx + 2] === "src" || parts[cliIdx + 2] === "dist")) {
|
||||
return { kind: "source" };
|
||||
}
|
||||
|
||||
// …/node_modules/@prismshadow/penguin-cli/…
|
||||
const nmIdx = parts.findIndex(
|
||||
(seg, i) =>
|
||||
seg === "node_modules" && parts[i + 1] === "@prismshadow" && parts[i + 2] === "penguin-cli",
|
||||
);
|
||||
if (nmIdx >= 0) {
|
||||
return { kind: "npm", globalRoot: join(nmIdx + 1) };
|
||||
}
|
||||
|
||||
// <installDir>/lib/dist/index.js
|
||||
if (parts.length >= 3) {
|
||||
const [lib, dist] = [parts[parts.length - 3], parts[parts.length - 2]];
|
||||
if (lib === "lib" && dist === "dist") {
|
||||
return { kind: "tarball", installDir: join(parts.length - 3) };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the package manager that owns a global `node_modules` root, from the root's own path.
|
||||
* Returns null when it is not recognizable — the caller then prints the command for the user to
|
||||
* run rather than guessing, because an `npm i -g` over a pnpm-managed install leaves two copies
|
||||
* and a broken shim.
|
||||
*/
|
||||
export function detectPackageManager(globalRoot: string): PackageManager | null {
|
||||
const parts = segments(globalRoot).map((s) => s.toLowerCase());
|
||||
if (parts.includes(".pnpm") || (parts.includes("pnpm") && parts.includes("global")))
|
||||
return "pnpm";
|
||||
if (parts.includes(".bun")) return "bun";
|
||||
if (parts.includes("yarn") && parts.includes("global")) return "yarn";
|
||||
if (parts.includes("npm") || parts.includes("lib") || parts.includes("node_modules"))
|
||||
return "npm";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The global-install command for a manager, at a specific version. */
|
||||
export function globalInstallCommand(
|
||||
manager: PackageManager,
|
||||
version: string,
|
||||
): { command: string; args: string[] } {
|
||||
const spec = `@prismshadow/penguin-cli@${version}`;
|
||||
if (manager === "pnpm") return { command: "pnpm", args: ["add", "-g", spec] };
|
||||
if (manager === "yarn") return { command: "yarn", args: ["global", "add", spec] };
|
||||
if (manager === "bun") return { command: "bun", args: ["add", "-g", spec] };
|
||||
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:
|
||||
*
|
||||
* - `PENGUIN_INSTALL_DIR` is passed whenever the install is not at the default `~/.penguin`, or
|
||||
* the upgrade would silently relocate it;
|
||||
* - `--universal` is passed when the current install has no bundled `node/` directory, or the user
|
||||
* would silently gain a runtime they deliberately did not install (and lose it in reverse);
|
||||
* - `PENGUIN_VERSION` pins the target when `--release` was given.
|
||||
*
|
||||
* Pure so every combination is unit-testable; the caller supplies the two facts that need the
|
||||
* filesystem (`installDir`, `hasBundledNode`).
|
||||
*/
|
||||
export function buildInstallerInvocation(opts: {
|
||||
scriptPath: string;
|
||||
installDir: string;
|
||||
hasBundledNode: boolean;
|
||||
defaultInstallDir: string;
|
||||
version?: string;
|
||||
}): { args: string[]; env: Record<string, string> } {
|
||||
const args = [opts.scriptPath];
|
||||
if (!opts.hasBundledNode) args.push("--universal");
|
||||
const env: Record<string, string> = {};
|
||||
if (path.resolve(opts.installDir) !== path.resolve(opts.defaultInstallDir)) {
|
||||
env.PENGUIN_INSTALL_DIR = opts.installDir;
|
||||
}
|
||||
if (opts.version) env.PENGUIN_VERSION = `v${normalizeVersion(opts.version)}`;
|
||||
return { args, env };
|
||||
}
|
||||
|
||||
/** Download URL for the installer of a given release (latest when no version is pinned). */
|
||||
export function installerUrl(version?: string): string {
|
||||
const base = `https://github.com/${REPO_SLUG}/releases`;
|
||||
return version
|
||||
? `${base}/download/v${normalizeVersion(version)}/install.sh`
|
||||
: `${base}/latest/download/install.sh`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the newest published version from the Releases API. Every failure mode the API actually
|
||||
* produces gets its own message instead of a stack trace: no network, a rate-limited 403 (which
|
||||
* arrives with no useful body from an unauthenticated client), any other HTTP status, and a body
|
||||
* that parses but carries no usable `tag_name`.
|
||||
*/
|
||||
export async function fetchLatestVersion(t: Messages): Promise<string> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(LATEST_RELEASE_API, {
|
||||
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-cli" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
throw new Error(t.update.networkFailed(LATEST_RELEASE_API));
|
||||
}
|
||||
if (res.status === 403 || res.status === 429) throw new Error(t.update.rateLimited());
|
||||
if (!res.ok) throw new Error(t.update.apiFailed(res.status));
|
||||
let tag: unknown;
|
||||
try {
|
||||
tag = ((await res.json()) as { tag_name?: unknown }).tag_name;
|
||||
} catch {
|
||||
throw new Error(t.update.apiMalformed());
|
||||
}
|
||||
if (typeof tag !== "string" || normalizeVersion(tag) === "")
|
||||
throw new Error(t.update.apiMalformed());
|
||||
return normalizeVersion(tag);
|
||||
}
|
||||
|
||||
/** Interactive y/N confirmation; SIGINT and stream close both count as "no", so it can never hang. */
|
||||
function confirmYes(prompt: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
process.off("SIGINT", onSigint);
|
||||
rl.close();
|
||||
resolve(value);
|
||||
};
|
||||
const onSigint = () => finish(false);
|
||||
process.once("SIGINT", onSigint);
|
||||
rl.on("close", () => finish(false));
|
||||
rl.question(prompt, (answer) => finish(/^y(es)?$/i.test(answer.trim())));
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs a child process to completion, inheriting stdio; resolves with its exit code. */
|
||||
function run(command: string, args: string[], env: Record<string, string>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
child.on("error", () => resolve(-1));
|
||||
child.on("close", (code) => resolve(code ?? -1));
|
||||
});
|
||||
}
|
||||
|
||||
/** The real path of this module, with the ~/.local/bin/penguin symlink resolved. */
|
||||
function selfPath(): string {
|
||||
const p = fileURLToPath(import.meta.url);
|
||||
try {
|
||||
return realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the command decided to do, once the two versions and the install layout are known.
|
||||
*
|
||||
* `report` and `up-to-date` change nothing; `refuse` prints a reason and stops; `npm` and
|
||||
* `tarball` are the only two that go on to confirm and spawn anything.
|
||||
*/
|
||||
export type UpdatePlan =
|
||||
| { action: "report"; current: string; target: string; comparison: number }
|
||||
| { action: "up-to-date"; current: string }
|
||||
| { action: "refuse"; reason: "source" }
|
||||
| { action: "refuse"; reason: "unknown-install"; modulePath: string }
|
||||
| { action: "refuse"; reason: "unknown-manager"; globalRoot: string; target: string }
|
||||
| { action: "refuse"; reason: "windows-global"; command: string }
|
||||
| { action: "refuse"; reason: "windows-installer" }
|
||||
| { action: "npm"; manager: PackageManager; command: string; args: string[] }
|
||||
| { action: "tarball"; installDir: string };
|
||||
|
||||
/**
|
||||
* The whole decision, as one pure function: which of the outcomes above this invocation is, given
|
||||
* the running version, the resolved target, the flags, and the layout this CLI was installed as.
|
||||
*
|
||||
* Everything that needs the outside world stays with the caller — resolving the latest version,
|
||||
* probing for a bundled `node/`, prompting, spawning — so each branch below (including the two
|
||||
* refusals that exist only to avoid a misleading failure) is directly testable without a network
|
||||
* or a child process. The order is deliberate: `--check` reports and never upgrades, an install
|
||||
* already on the target is done before its layout even matters, a source checkout and an
|
||||
* unrecognised layout are refused before anything is downloaded, and Windows is refused inside
|
||||
* whichever branch applies so its message can name the command that would have run.
|
||||
*/
|
||||
export function planUpdate(input: {
|
||||
current: string;
|
||||
target: string;
|
||||
check?: boolean;
|
||||
install: InstallInfo;
|
||||
modulePath: string;
|
||||
platform: string;
|
||||
/** `~/.penguin`, passed in rather than read, so the tarball branch stays pure. */
|
||||
defaultInstallDir: string;
|
||||
}): UpdatePlan {
|
||||
const { current, target, install } = input;
|
||||
const comparison = compareVersions(target, current);
|
||||
|
||||
if (input.check) return { action: "report", current, target, comparison };
|
||||
if (comparison === 0) return { action: "up-to-date", current };
|
||||
|
||||
if (install.kind === "source") return { action: "refuse", reason: "source" };
|
||||
if (install.kind === "unknown")
|
||||
return { action: "refuse", reason: "unknown-install", modulePath: input.modulePath };
|
||||
|
||||
if (install.kind === "npm") {
|
||||
const globalRoot = install.globalRoot ?? "";
|
||||
const manager = detectPackageManager(globalRoot);
|
||||
if (!manager) return { action: "refuse", reason: "unknown-manager", globalRoot, target };
|
||||
const { command, args } = globalInstallCommand(manager, target);
|
||||
if (input.platform === "win32")
|
||||
return {
|
||||
action: "refuse",
|
||||
reason: "windows-global",
|
||||
command: `${command} ${args.join(" ")}`,
|
||||
};
|
||||
return { action: "npm", manager, command, args };
|
||||
}
|
||||
|
||||
if (input.platform === "win32") return { action: "refuse", reason: "windows-installer" };
|
||||
return { action: "tarball", installDir: install.installDir ?? input.defaultInstallDir };
|
||||
}
|
||||
|
||||
/** The line printed for a refusal — one message per reason, no fallthrough. */
|
||||
function refusalMessage(plan: Extract<UpdatePlan, { action: "refuse" }>, t: Messages): string {
|
||||
switch (plan.reason) {
|
||||
case "source":
|
||||
return t.update.sourceCheckout();
|
||||
case "unknown-install":
|
||||
return t.update.unknownInstall(plan.modulePath);
|
||||
case "unknown-manager":
|
||||
return t.update.npmUnknownManager(plan.globalRoot, plan.target);
|
||||
case "windows-global":
|
||||
return t.update.windowsGlobalInstall(plan.command);
|
||||
case "windows-installer":
|
||||
return t.update.windowsUnsupported();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerUpdateCommand(program: Command, t: Messages): void {
|
||||
program
|
||||
.command("update")
|
||||
.description(t.update.desc)
|
||||
.option("--check", t.update.check)
|
||||
.option("--release <tag>", t.update.releaseOpt)
|
||||
.option("-y, --yes", t.update.yes)
|
||||
.action(async (opts: { check?: boolean; release?: string; yes?: boolean }) => {
|
||||
const current = VERSION;
|
||||
const target = opts.release ? normalizeVersion(opts.release) : await fetchLatestVersion(t);
|
||||
const modulePath = selfPath();
|
||||
const defaultInstallDir = path.join(homedir(), ".penguin");
|
||||
const plan = planUpdate({
|
||||
current,
|
||||
target,
|
||||
...(opts.check !== undefined ? { check: opts.check } : {}),
|
||||
install: detectInstall(modulePath),
|
||||
modulePath,
|
||||
platform: process.platform,
|
||||
defaultInstallDir,
|
||||
});
|
||||
|
||||
if (plan.action === "report") {
|
||||
process.stdout.write(`${t.update.checkReport(plan.current, plan.target)}\n`);
|
||||
process.stdout.write(
|
||||
`${plan.comparison > 0 ? t.update.upgradeAvailable(plan.target) : plan.comparison < 0 ? t.update.targetIsOlder(plan.target) : t.update.upToDate(plan.current)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (plan.action === "up-to-date") {
|
||||
process.stdout.write(`${t.update.upToDate(plan.current)}\n`);
|
||||
return;
|
||||
}
|
||||
if (plan.action === "refuse") {
|
||||
process.stdout.write(`${refusalMessage(plan, t)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- npm global install ---
|
||||
if (plan.action === "npm") {
|
||||
process.stdout.write(
|
||||
`${t.update.planNpm(current, target, plan.manager, `${plan.command} ${plan.args.join(" ")}`)}\n`,
|
||||
);
|
||||
if (!(await confirmUpgrade(opts.yes, t))) return;
|
||||
const code = await run(plan.command, plan.args, {});
|
||||
process.stdout.write(`${code === 0 ? t.update.done(target) : t.update.failed()}\n`);
|
||||
if (code !== 0) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- tarball install: re-run the installer, preserving this install's shape ---
|
||||
const installDir = plan.installDir;
|
||||
const hasBundledNode = existsSync(path.join(installDir, "node"));
|
||||
process.stdout.write(
|
||||
`${t.update.planTarball(current, target, installDir, !hasBundledNode)}\n`,
|
||||
);
|
||||
if (!(await confirmUpgrade(opts.yes, t))) return;
|
||||
|
||||
const url = installerUrl(opts.release ? target : undefined);
|
||||
let script: string;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
script = await res.text();
|
||||
} catch {
|
||||
process.stdout.write(`${t.update.installerFetchFailed(url)}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// A private 0700 directory with an unpredictable name, never a fixed path in the shared
|
||||
// world-writable temp dir: nobody else can pre-create this path as a symlink to overwrite,
|
||||
// or swap the file out between the write below and the spawn. `wx` refuses to truncate an
|
||||
// existing entry rather than inheriting its mode and owner. It is outside installDir on
|
||||
// purpose — the installer replaces that tree, and a shell script deleted mid-execution is
|
||||
// not something to rely on.
|
||||
const scriptDir = mkdtempSync(path.join(tmpdir(), "penguin-update-"));
|
||||
const scriptPath = path.join(scriptDir, "install.sh");
|
||||
writeFileSync(scriptPath, script, { mode: 0o700, flag: "wx" });
|
||||
|
||||
const { args, env } = buildInstallerInvocation({
|
||||
scriptPath,
|
||||
installDir,
|
||||
hasBundledNode,
|
||||
defaultInstallDir,
|
||||
version: opts.release ? target : undefined,
|
||||
});
|
||||
// Past this point the installer may delete the tree this process runs from. Everything below
|
||||
// is already-loaded code and already-resolved strings: no import, no file read, no re-entry.
|
||||
const code = await run("sh", args, env);
|
||||
// Safe only here: `close` has fired, so the child `sh` has exited and nothing is still
|
||||
// reading the script (install.sh runs to completion synchronously and backgrounds nothing —
|
||||
// it does its own `trap 'rm -rf "$TMP"' EXIT` cleanup and then returns). Deleting it any
|
||||
// earlier would pull the script out from under a running interpreter. `force` keeps a
|
||||
// failed cleanup from turning a successful upgrade into an error.
|
||||
rmSync(scriptDir, { recursive: true, force: true });
|
||||
process.stdout.write(`${code === 0 ? t.update.done(target) : t.update.failed()}\n`);
|
||||
if (code !== 0) process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How the confirmation gate resolves, decided before any I/O: `--yes` proceeds outright, a
|
||||
* non-interactive stdio pair has to be *told* rather than asked — a prompt nobody can answer would
|
||||
* hang the upgrade forever in a pipe or a CI job — and anything else gets the interactive prompt.
|
||||
*
|
||||
* Pure, so the "must pass --yes" path is testable without a pseudo-terminal.
|
||||
*/
|
||||
export function confirmationMode(
|
||||
yes: boolean | undefined,
|
||||
interactive: boolean,
|
||||
): "proceed" | "needs-yes" | "prompt" {
|
||||
if (yes) return "proceed";
|
||||
return interactive ? "prompt" : "needs-yes";
|
||||
}
|
||||
|
||||
/** Confirmation gate: `--yes` skips it; a non-TTY stdin must pass `--yes` rather than hang. */
|
||||
async function confirmUpgrade(yes: boolean | undefined, t: Messages): Promise<boolean> {
|
||||
const mode = confirmationMode(yes, Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
||||
if (mode === "proceed") return true;
|
||||
if (mode === "needs-yes") {
|
||||
process.stdout.write(`${t.update.needsYes()}\n`);
|
||||
return false;
|
||||
}
|
||||
if (await confirmYes(t.update.confirm())) return true;
|
||||
process.stdout.write(`${t.update.cancelled()}\n`);
|
||||
return false;
|
||||
}
|
||||
@@ -72,6 +72,46 @@ export interface Messages {
|
||||
host: string;
|
||||
noOpen: string;
|
||||
};
|
||||
/** `penguin update`: help text and every line the command can print. */
|
||||
update: {
|
||||
desc: string;
|
||||
check: string;
|
||||
releaseOpt: string;
|
||||
yes: string;
|
||||
/** `--check` header: the running version against the resolved target. */
|
||||
checkReport(current: string, latest: string): string;
|
||||
upgradeAvailable(target: string): string;
|
||||
upToDate(current: string): string;
|
||||
/** `--release` naming a release older than the running one: allowed, but said out loud. */
|
||||
targetIsOlder(target: string): string;
|
||||
/** Pre-confirmation plan for a tarball install (mechanism, target, install dir, data-dir guarantee). */
|
||||
planTarball(current: string, target: string, installDir: string, universal: boolean): string;
|
||||
/** Pre-confirmation plan for a global npm install. */
|
||||
planNpm(current: string, target: string, manager: string, command: string): string;
|
||||
confirm(): string;
|
||||
/** stdin is not a TTY: require --yes instead of blocking on a prompt nobody can answer. */
|
||||
needsYes(): string;
|
||||
cancelled(): string;
|
||||
done(version: string): string;
|
||||
failed(): string;
|
||||
/** Running from a source checkout: refuse, because overwriting a working tree destroys work. */
|
||||
sourceCheckout(): string;
|
||||
unknownInstall(modulePath: string): string;
|
||||
/** A global install whose package manager could not be identified: print the command, never guess. */
|
||||
npmUnknownManager(globalRoot: string, target: string): string;
|
||||
/** Windows, tarball install: the official installer is a POSIX shell script. */
|
||||
windowsUnsupported(): string;
|
||||
/**
|
||||
* Windows, global install: `spawn` cannot run a `.cmd` shim without a shell, so hand the user
|
||||
* the exact command instead of failing generically.
|
||||
*/
|
||||
windowsGlobalInstall(command: string): string;
|
||||
networkFailed(url: string): string;
|
||||
rateLimited(): string;
|
||||
apiFailed(status: number): string;
|
||||
apiMalformed(): string;
|
||||
installerFetchFailed(url: string): string;
|
||||
};
|
||||
|
||||
// —— Runtime output ——
|
||||
header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string;
|
||||
@@ -212,6 +252,61 @@ const en: Messages = {
|
||||
host: "Listen address (falls back to the HOST env var, default 127.0.0.1)",
|
||||
noOpen: "Do not open a browser automatically",
|
||||
},
|
||||
update: {
|
||||
desc: "Upgrade this PenguinHarness install in place",
|
||||
check: "Only report the current and latest versions; change nothing",
|
||||
releaseOpt:
|
||||
"Target a specific release tag instead of the latest (e.g. v0.1.2 or 0.1.2); named --release because -v/--version is the CLI's own version flag",
|
||||
yes: "Skip the confirmation prompt",
|
||||
checkReport: (current, latest) => `Installed ${current} · latest ${latest}`,
|
||||
upgradeAvailable: (target) =>
|
||||
`An upgrade is available: run \`penguin update\` to install ${target}.`,
|
||||
upToDate: (current) => `Already on the latest version (${current}); nothing to do.`,
|
||||
targetIsOlder: (target) =>
|
||||
`${target} is older than the installed version — this would be a downgrade.`,
|
||||
planTarball: (current, target, installDir, universal) =>
|
||||
[
|
||||
`Upgrade ${current} -> ${target}`,
|
||||
` how: re-run the official installer (this install came from the tarball)`,
|
||||
` install dir: ${installDir}${universal ? " (universal package, no bundled Node runtime)" : ""}`,
|
||||
` replaces: bin, lib, web${universal ? "" : ", node"} — your data dir is NOT touched`,
|
||||
].join("\n"),
|
||||
planNpm: (current, target, manager, command) =>
|
||||
[
|
||||
`Upgrade ${current} -> ${target}`,
|
||||
` how: global ${manager} install (this install came from ${manager})`,
|
||||
` command: ${command}`,
|
||||
` your data dir is NOT touched`,
|
||||
].join("\n"),
|
||||
confirm: () => "Proceed? [y/N] ",
|
||||
needsYes: () =>
|
||||
"Not running in a terminal, so the confirmation cannot be answered. Re-run with --yes to upgrade non-interactively.",
|
||||
cancelled: () => "Cancelled; nothing was changed.",
|
||||
done: (version) =>
|
||||
`PenguinHarness ${version} installed. Run \`penguin --version\` in a new shell to confirm.`,
|
||||
failed: () => "Upgrade failed; the previous install was left in place where possible.",
|
||||
sourceCheckout: () =>
|
||||
"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`).",
|
||||
unknownInstall: (modulePath) =>
|
||||
`Cannot tell how this penguin was installed (running from ${modulePath}), so it will not be replaced. Re-install with the official installer, or upgrade with the package manager you used.`,
|
||||
npmUnknownManager: (globalRoot, target) =>
|
||||
`This is a global install under ${globalRoot}, but the package manager that owns it could not be identified. Upgrade it yourself with that manager, e.g. \`npm install -g @prismshadow/penguin-cli@${target}\`.`,
|
||||
windowsUnsupported: () =>
|
||||
"The official installer is a POSIX shell script and does not run on Windows. Re-install from the GitHub Releases page, or use a global npm install instead.",
|
||||
windowsGlobalInstall: (command) =>
|
||||
[
|
||||
"On Windows, penguin cannot run your package manager for you: Node will not execute an npm/pnpm/yarn `.cmd` shim without a shell.",
|
||||
"Run this yourself in a terminal, then reopen it:",
|
||||
` ${command}`,
|
||||
].join("\n"),
|
||||
networkFailed: (url) => `Could not reach ${url}. Check your network and retry.`,
|
||||
rateLimited: () =>
|
||||
"GitHub rate-limited the release lookup. Wait a few minutes and retry, or pass --release <tag> to skip the lookup.",
|
||||
apiFailed: (status) => `The GitHub release lookup failed with HTTP ${status}.`,
|
||||
apiMalformed: () =>
|
||||
"The GitHub release lookup returned an unexpected response with no usable version tag.",
|
||||
installerFetchFailed: (url) => `Could not download the installer from ${url}.`,
|
||||
},
|
||||
|
||||
header,
|
||||
chatHints: () =>
|
||||
@@ -326,6 +421,57 @@ const zh: Messages = {
|
||||
host: "监听地址(其次取环境变量 HOST,缺省 127.0.0.1)",
|
||||
noOpen: "不自动打开浏览器",
|
||||
},
|
||||
update: {
|
||||
desc: "原地升级当前的 PenguinHarness 安装",
|
||||
check: "只报告当前版本与最新版本,不做任何修改",
|
||||
releaseOpt:
|
||||
"指定目标版本而不是最新版(如 v0.1.2 或 0.1.2);之所以叫 --release,是因为 -v/--version 是 CLI 自身的版本参数",
|
||||
yes: "跳过确认提示",
|
||||
checkReport: (current, latest) => `已安装 ${current} · 最新 ${latest}`,
|
||||
upgradeAvailable: (target) => `有可用升级:执行 \`penguin update\` 安装 ${target}。`,
|
||||
upToDate: (current) => `已是最新版本(${current}),无需升级。`,
|
||||
targetIsOlder: (target) => `${target} 低于当前已安装的版本——这将是一次降级。`,
|
||||
planTarball: (current, target, installDir, universal) =>
|
||||
[
|
||||
`升级 ${current} -> ${target}`,
|
||||
` 方式: 重新执行官方安装脚本(当前安装来自 tarball)`,
|
||||
` 安装目录:${installDir}${universal ? "(universal 包,不含内置 Node 运行时)" : ""}`,
|
||||
` 将替换: bin、lib、web${universal ? "" : "、node"}——数据目录不会被改动`,
|
||||
].join("\n"),
|
||||
planNpm: (current, target, manager, command) =>
|
||||
[
|
||||
`升级 ${current} -> ${target}`,
|
||||
` 方式: ${manager} 全局安装(当前安装来自 ${manager})`,
|
||||
` 命令: ${command}`,
|
||||
` 数据目录不会被改动`,
|
||||
].join("\n"),
|
||||
confirm: () => "确认继续?[y/N] ",
|
||||
needsYes: () => "当前不在终端中,无法回答确认提示。请加 --yes 以非交互方式升级。",
|
||||
cancelled: () => "已取消,未做任何修改。",
|
||||
done: (version) =>
|
||||
`PenguinHarness ${version} 安装完成。在新 shell 中执行 \`penguin --version\` 确认。`,
|
||||
failed: () => "升级失败;在可能的情况下已保留原有安装。",
|
||||
sourceCheckout: () =>
|
||||
"当前 penguin 运行自源码检出,无需下载——请用 `git pull` 更新并重新构建(`pnpm install && pnpm -r build`)。",
|
||||
unknownInstall: (modulePath) =>
|
||||
`无法判断当前 penguin 的安装方式(运行自 ${modulePath}),因此不会替换它。请用官方安装脚本重新安装,或用你当初使用的包管理器升级。`,
|
||||
npmUnknownManager: (globalRoot, target) =>
|
||||
`这是位于 ${globalRoot} 的全局安装,但无法确定是哪个包管理器安装的。请自行用该包管理器升级,例如 \`npm install -g @prismshadow/penguin-cli@${target}\`。`,
|
||||
windowsUnsupported: () =>
|
||||
"官方安装脚本是 POSIX shell 脚本,无法在 Windows 上运行。请从 GitHub Releases 页面重新安装,或改用 npm 全局安装。",
|
||||
windowsGlobalInstall: (command) =>
|
||||
[
|
||||
"在 Windows 上,penguin 无法代你调用包管理器:Node 不会在没有 shell 的情况下执行 npm/pnpm/yarn 的 `.cmd` 包装脚本。",
|
||||
"请在终端里自行执行下面的命令,然后重新打开终端:",
|
||||
` ${command}`,
|
||||
].join("\n"),
|
||||
networkFailed: (url) => `无法访问 ${url}。请检查网络后重试。`,
|
||||
rateLimited: () =>
|
||||
"GitHub 对版本查询做了限流。请等待几分钟后重试,或用 --release <tag> 跳过查询。",
|
||||
apiFailed: (status) => `GitHub 版本查询失败,HTTP ${status}。`,
|
||||
apiMalformed: () => "GitHub 版本查询返回了非预期的响应,其中没有可用的版本号。",
|
||||
installerFetchFailed: (url) => `无法从 ${url} 下载安装脚本。`,
|
||||
},
|
||||
|
||||
header,
|
||||
chatHints: () =>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* penguin chat ...
|
||||
* penguin run --message ...
|
||||
* penguin server|web ...
|
||||
* penguin update ...
|
||||
* Docs: packages/docs/content/cli.{zh,en}.md (site path /docs/cli).
|
||||
*/
|
||||
import "dotenv/config";
|
||||
@@ -18,6 +19,7 @@ import { registerConfigCommand } from "./commands/config.js";
|
||||
import { registerRunCommand } from "./commands/run.js";
|
||||
import { registerChatCommand } from "./commands/chat.js";
|
||||
import { registerServeCommands } from "./commands/serve.js";
|
||||
import { registerUpdateCommand } from "./commands/update.js";
|
||||
import { defaultMessages } from "./i18n.js";
|
||||
|
||||
// Language comes from the PENGUIN_LANG env var (default en); used consistently for
|
||||
@@ -34,6 +36,7 @@ registerConfigCommand(program, t);
|
||||
registerRunCommand(program, t);
|
||||
registerChatCommand(program, t);
|
||||
registerServeCommands(program, t);
|
||||
registerUpdateCommand(program, t);
|
||||
|
||||
// Show help only when no subcommand is given (empty input); do not error.
|
||||
program.action(() => {
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* `penguin update`'s pure pieces: install-kind detection over the three real layouts, package-
|
||||
* manager identification for global installs, version normalisation/comparison, the installer
|
||||
* argv/env built for each combination of install dir and bundled runtime, and the decision the
|
||||
* command makes before it touches anything (planUpdate) plus its confirmation gate.
|
||||
*
|
||||
* No network and no filesystem mutation — every function under test takes its inputs as arguments.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
buildInstallerInvocation,
|
||||
compareVersions,
|
||||
confirmationMode,
|
||||
detectInstall,
|
||||
detectPackageManager,
|
||||
globalInstallCommand,
|
||||
installerUrl,
|
||||
normalizeVersion,
|
||||
planUpdate,
|
||||
registerUpdateCommand,
|
||||
} from "../src/commands/update.js";
|
||||
import { getMessages } from "../src/i18n.js";
|
||||
|
||||
describe("detectInstall (how this CLI was installed, from its own real path)", () => {
|
||||
it("tarball: <installDir>/lib/dist/index.js, the layout install.sh unpacks", () => {
|
||||
expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
|
||||
kind: "tarball",
|
||||
installDir: "/home/me/.penguin",
|
||||
});
|
||||
});
|
||||
|
||||
it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
|
||||
expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
|
||||
kind: "tarball",
|
||||
installDir: "/opt/tools/penguin",
|
||||
});
|
||||
});
|
||||
|
||||
it("npm global: npm's own prefix layout", () => {
|
||||
expect(
|
||||
detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
|
||||
).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
|
||||
});
|
||||
|
||||
it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
|
||||
const p =
|
||||
"/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
|
||||
const info = detectInstall(p);
|
||||
expect(info.kind).toBe("npm");
|
||||
expect(info.globalRoot).toContain(".pnpm");
|
||||
});
|
||||
|
||||
it("source checkout: the built dist inside the monorepo", () => {
|
||||
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
|
||||
kind: "source",
|
||||
});
|
||||
});
|
||||
|
||||
it("source checkout: tsx running src directly", () => {
|
||||
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
|
||||
kind: "source",
|
||||
});
|
||||
});
|
||||
|
||||
it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
|
||||
expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
|
||||
kind: "source",
|
||||
});
|
||||
});
|
||||
|
||||
it("anything else is unknown rather than guessed", () => {
|
||||
expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
|
||||
expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPackageManager (which manager owns a global node_modules root)", () => {
|
||||
it("pnpm: the global store or the .pnpm virtual dir", () => {
|
||||
expect(detectPackageManager("/home/me/.local/share/pnpm/global/5/node_modules")).toBe("pnpm");
|
||||
expect(
|
||||
detectPackageManager("/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/x/node_modules"),
|
||||
).toBe("pnpm");
|
||||
});
|
||||
it("npm: the usual prefixes", () => {
|
||||
expect(detectPackageManager("/usr/local/lib/node_modules")).toBe("npm");
|
||||
expect(detectPackageManager("/home/me/.npm-global/lib/node_modules")).toBe("npm");
|
||||
});
|
||||
it("yarn and bun", () => {
|
||||
expect(detectPackageManager("/home/me/.config/yarn/global/node_modules")).toBe("yarn");
|
||||
expect(detectPackageManager("/home/me/.bun/install/global/node_modules")).toBe("bun");
|
||||
});
|
||||
it("returns null when unrecognizable, so the caller prints a command instead of guessing", () => {
|
||||
expect(detectPackageManager("/weird/place")).toBeNull();
|
||||
expect(detectPackageManager("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalInstallCommand", () => {
|
||||
it("uses each manager's own global-install spelling", () => {
|
||||
expect(globalInstallCommand("pnpm", "0.1.2")).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
||||
});
|
||||
expect(globalInstallCommand("npm", "0.1.2")).toEqual({
|
||||
command: "npm",
|
||||
args: ["install", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
||||
});
|
||||
expect(globalInstallCommand("yarn", "0.1.2")).toEqual({
|
||||
command: "yarn",
|
||||
args: ["global", "add", "@prismshadow/penguin-cli@0.1.2"],
|
||||
});
|
||||
expect(globalInstallCommand("bun", "0.1.2")).toEqual({
|
||||
command: "bun",
|
||||
args: ["add", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeVersion / compareVersions", () => {
|
||||
it("accepts both v-prefixed and bare tags", () => {
|
||||
expect(normalizeVersion("v0.1.2")).toBe("0.1.2");
|
||||
expect(normalizeVersion("0.1.2")).toBe("0.1.2");
|
||||
expect(normalizeVersion(" V0.1.2 ")).toBe("0.1.2");
|
||||
});
|
||||
it("equal versions compare equal regardless of the v prefix", () => {
|
||||
expect(compareVersions("v0.1.1", "0.1.1")).toBe(0);
|
||||
});
|
||||
it("orders newer above older, including across component widths", () => {
|
||||
expect(compareVersions("0.1.2", "0.1.1")).toBe(1);
|
||||
expect(compareVersions("0.1.1", "0.1.2")).toBe(-1);
|
||||
expect(compareVersions("0.2.0", "0.1.9")).toBe(1);
|
||||
expect(compareVersions("1.0.0", "0.99.99")).toBe(1);
|
||||
expect(compareVersions("0.1.10", "0.1.9")).toBe(1);
|
||||
});
|
||||
it("treats a missing component as 0", () => {
|
||||
expect(compareVersions("1", "1.0.0")).toBe(0);
|
||||
expect(compareVersions("1.1", "1.0.9")).toBe(1);
|
||||
});
|
||||
it("a malformed tag can never look like an available upgrade", () => {
|
||||
expect(compareVersions("not-a-version", "0.1.1")).toBe(-1);
|
||||
expect(compareVersions("", "0.1.1")).toBe(-1);
|
||||
});
|
||||
it("reads each component as far as it is numeric, which is what the docblock promises", () => {
|
||||
// parseInt semantics, pinned so the comment and the behaviour cannot drift apart again:
|
||||
// leading digits win, and a component with none counts as 0.
|
||||
expect(compareVersions("0.1.2abc", "0.1.2")).toBe(0);
|
||||
expect(compareVersions("0.1.x", "0.1.0")).toBe(0);
|
||||
expect(compareVersions("0.1.x", "0.1.1")).toBe(-1);
|
||||
});
|
||||
it("suffixes are invisible, so a pre-release compares equal to its release", () => {
|
||||
// Documented limitation rather than a bug: this project publishes plain vX.Y.Z tags only.
|
||||
expect(compareVersions("0.1.2-rc1", "0.1.2")).toBe(0);
|
||||
expect(compareVersions("0.1.2-rc1", "0.1.1")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installerUrl", () => {
|
||||
it("resolves the latest release when no version is pinned", () => {
|
||||
expect(installerUrl()).toBe(
|
||||
"https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh",
|
||||
);
|
||||
});
|
||||
it("pins a tag, normalising the v prefix", () => {
|
||||
const expected =
|
||||
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.1.2/install.sh";
|
||||
expect(installerUrl("0.1.2")).toBe(expected);
|
||||
expect(installerUrl("v0.1.2")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInstallerInvocation (preserves the shape of the install being upgraded)", () => {
|
||||
const base = {
|
||||
scriptPath: "/tmp/penguin-install-1.sh",
|
||||
defaultInstallDir: "/home/me/.penguin",
|
||||
};
|
||||
|
||||
it("default dir + bundled runtime: no flags, no env", () => {
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/home/me/.penguin",
|
||||
hasBundledNode: true,
|
||||
}),
|
||||
).toEqual({ args: ["/tmp/penguin-install-1.sh"], env: {} });
|
||||
});
|
||||
|
||||
it("no bundled node/ means the universal package: pass --universal so the runtime is not silently added", () => {
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/home/me/.penguin",
|
||||
hasBundledNode: false,
|
||||
}),
|
||||
).toEqual({ args: ["/tmp/penguin-install-1.sh", "--universal"], env: {} });
|
||||
});
|
||||
|
||||
it("a non-default install dir is passed through, or the upgrade would relocate the install", () => {
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/opt/penguin",
|
||||
hasBundledNode: true,
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["/tmp/penguin-install-1.sh"],
|
||||
env: { PENGUIN_INSTALL_DIR: "/opt/penguin" },
|
||||
});
|
||||
});
|
||||
|
||||
it("a pinned version becomes PENGUIN_VERSION, always v-prefixed for the release tag", () => {
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/home/me/.penguin",
|
||||
hasBundledNode: true,
|
||||
version: "0.1.2",
|
||||
}).env,
|
||||
).toEqual({ PENGUIN_VERSION: "v0.1.2" });
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/home/me/.penguin",
|
||||
hasBundledNode: true,
|
||||
version: "v0.1.2",
|
||||
}).env,
|
||||
).toEqual({ PENGUIN_VERSION: "v0.1.2" });
|
||||
});
|
||||
|
||||
it("all three at once: custom dir, universal package, pinned version", () => {
|
||||
expect(
|
||||
buildInstallerInvocation({
|
||||
...base,
|
||||
installDir: "/opt/penguin",
|
||||
hasBundledNode: false,
|
||||
version: "v0.2.0",
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["/tmp/penguin-install-1.sh", "--universal"],
|
||||
env: { PENGUIN_INSTALL_DIR: "/opt/penguin", PENGUIN_VERSION: "v0.2.0" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("planUpdate (what the command decides before it touches anything)", () => {
|
||||
const base = {
|
||||
current: "0.1.1",
|
||||
target: "0.1.2",
|
||||
modulePath: "/home/me/.penguin/lib/dist/index.js",
|
||||
platform: "linux",
|
||||
defaultInstallDir: "/home/me/.penguin",
|
||||
};
|
||||
const tarball = { kind: "tarball", installDir: "/home/me/.penguin" } as const;
|
||||
const npmGlobal = { kind: "npm", globalRoot: "/usr/local/lib/node_modules" } as const;
|
||||
|
||||
it("--check reports the comparison and never upgrades, even when one is available", () => {
|
||||
expect(planUpdate({ ...base, check: true, install: tarball })).toEqual({
|
||||
action: "report",
|
||||
current: "0.1.1",
|
||||
target: "0.1.2",
|
||||
comparison: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("--check reports a downgrade and an up-to-date install without upgrading either", () => {
|
||||
expect(planUpdate({ ...base, target: "0.1.0", check: true, install: tarball })).toMatchObject({
|
||||
action: "report",
|
||||
comparison: -1,
|
||||
});
|
||||
expect(planUpdate({ ...base, target: "0.1.1", check: true, install: tarball })).toMatchObject({
|
||||
action: "report",
|
||||
comparison: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("--check reports even from a source checkout, where an upgrade would be refused", () => {
|
||||
// Reporting changes nothing, so the layout must not gate it.
|
||||
expect(planUpdate({ ...base, check: true, install: { kind: "source" } }).action).toBe("report");
|
||||
});
|
||||
|
||||
it("an install already on the target exits without touching anything", () => {
|
||||
expect(planUpdate({ ...base, target: "0.1.1", install: tarball })).toEqual({
|
||||
action: "up-to-date",
|
||||
current: "0.1.1",
|
||||
});
|
||||
// Decided before the layout matters: even an unknown install is simply up to date.
|
||||
expect(planUpdate({ ...base, target: "v0.1.1", install: { kind: "unknown" } }).action).toBe(
|
||||
"up-to-date",
|
||||
);
|
||||
});
|
||||
|
||||
it("a source checkout is refused, because overwriting a working tree destroys work", () => {
|
||||
expect(planUpdate({ ...base, install: { kind: "source" } })).toEqual({
|
||||
action: "refuse",
|
||||
reason: "source",
|
||||
});
|
||||
});
|
||||
|
||||
it("an unrecognised layout is refused and names the path it was running from", () => {
|
||||
expect(
|
||||
planUpdate({ ...base, modulePath: "/random/place/index.js", install: { kind: "unknown" } }),
|
||||
).toEqual({
|
||||
action: "refuse",
|
||||
reason: "unknown-install",
|
||||
modulePath: "/random/place/index.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("a global install upgrades through its own manager", () => {
|
||||
expect(planUpdate({ ...base, install: npmGlobal })).toEqual({
|
||||
action: "npm",
|
||||
manager: "npm",
|
||||
command: "npm",
|
||||
args: ["install", "-g", "@prismshadow/penguin-cli@0.1.2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("a global install with an unidentifiable manager is refused rather than guessed", () => {
|
||||
expect(planUpdate({ ...base, install: { kind: "npm", globalRoot: "/weird/place" } })).toEqual({
|
||||
action: "refuse",
|
||||
reason: "unknown-manager",
|
||||
globalRoot: "/weird/place",
|
||||
target: "0.1.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("a tarball install re-runs the installer at the dir it was detected in", () => {
|
||||
expect(planUpdate({ ...base, install: tarball })).toEqual({
|
||||
action: "tarball",
|
||||
installDir: "/home/me/.penguin",
|
||||
});
|
||||
expect(
|
||||
planUpdate({ ...base, install: { kind: "tarball", installDir: "/opt/penguin" } }),
|
||||
).toEqual({ action: "tarball", installDir: "/opt/penguin" });
|
||||
// No installDir on the info (shouldn't happen, but the fallback is the default install dir).
|
||||
expect(planUpdate({ ...base, install: { kind: "tarball" } })).toEqual({
|
||||
action: "tarball",
|
||||
installDir: "/home/me/.penguin",
|
||||
});
|
||||
});
|
||||
|
||||
it("Windows is refused on the tarball path: the installer is a POSIX shell script", () => {
|
||||
expect(planUpdate({ ...base, platform: "win32", install: tarball })).toEqual({
|
||||
action: "refuse",
|
||||
reason: "windows-installer",
|
||||
});
|
||||
});
|
||||
|
||||
it("Windows is refused on the npm path too, handing over the exact command to run", () => {
|
||||
// spawn() cannot run a .cmd shim without a shell, so this must not reach the spawn and
|
||||
// fail with a generic message.
|
||||
expect(planUpdate({ ...base, platform: "win32", install: npmGlobal })).toEqual({
|
||||
action: "refuse",
|
||||
reason: "windows-global",
|
||||
command: "npm install -g @prismshadow/penguin-cli@0.1.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("every refusal has a message in both languages", () => {
|
||||
const plans = [
|
||||
planUpdate({ ...base, install: { kind: "source" } }),
|
||||
planUpdate({ ...base, install: { kind: "unknown" } }),
|
||||
planUpdate({ ...base, install: { kind: "npm", globalRoot: "/weird/place" } }),
|
||||
planUpdate({ ...base, platform: "win32", install: tarball }),
|
||||
planUpdate({ ...base, platform: "win32", install: npmGlobal }),
|
||||
];
|
||||
for (const lang of ["en", "zh"] as const) {
|
||||
const t = getMessages(lang);
|
||||
expect(plans.map((p) => p.action)).toEqual(Array(plans.length).fill("refuse"));
|
||||
expect(t.update.sourceCheckout()).toBeTruthy();
|
||||
expect(t.update.unknownInstall("/x")).toContain("/x");
|
||||
expect(t.update.npmUnknownManager("/weird/place", "0.1.2")).toContain("/weird/place");
|
||||
expect(t.update.windowsUnsupported()).toBeTruthy();
|
||||
// The Windows global-install message is only useful if it carries the command verbatim.
|
||||
expect(t.update.windowsGlobalInstall("pnpm add -g pkg@1")).toContain("pnpm add -g pkg@1");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmationMode (the gate in front of every upgrade)", () => {
|
||||
it("--yes proceeds without a prompt, interactive or not", () => {
|
||||
expect(confirmationMode(true, true)).toBe("proceed");
|
||||
expect(confirmationMode(true, false)).toBe("proceed");
|
||||
});
|
||||
it("a non-TTY without --yes demands --yes instead of hanging on a prompt nobody can answer", () => {
|
||||
expect(confirmationMode(undefined, false)).toBe("needs-yes");
|
||||
expect(confirmationMode(false, false)).toBe("needs-yes");
|
||||
expect(getMessages("en").update.needsYes()).toContain("--yes");
|
||||
expect(getMessages("zh").update.needsYes()).toContain("--yes");
|
||||
});
|
||||
it("a terminal without --yes gets the interactive prompt", () => {
|
||||
expect(confirmationMode(undefined, true)).toBe("prompt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command registration", () => {
|
||||
it("registers `update` with its three options in both languages", () => {
|
||||
for (const lang of ["en", "zh"] as const) {
|
||||
const program = new Command();
|
||||
registerUpdateCommand(program, getMessages(lang));
|
||||
const cmd = program.commands.find((c) => c.name() === "update");
|
||||
expect(cmd).toBeDefined();
|
||||
const flags = cmd?.options.map((o) => o.flags) ?? [];
|
||||
expect(flags).toContain("--check");
|
||||
expect(flags).toContain("--release <tag>");
|
||||
expect(flags).toContain("-y, --yes");
|
||||
expect(cmd?.description()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user