feat(cli): add update download source selection (#196)

This commit is contained in:
Laodouuu
2026-08-04 23:13:44 +08:00
committed by GitHub
parent d87c993dd4
commit 8362eca6e5
5 changed files with 480 additions and 30 deletions
+211 -24
View File
@@ -11,12 +11,14 @@
* 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.
* Release discovery and installer download use the same environment contract as the public
* installer entry point: an explicit PENGUIN_DOWNLOAD_BASE_URL has highest download priority;
* otherwise auto prefers the OSS latest pointer and immutable release, then falls back to the same
* GitHub tag, while oss and github are strict. 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
@@ -60,7 +62,7 @@ import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
import { VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
import type { Command } from "commander";
import type { Messages } from "../i18n.js";
import type { InstallerSource, 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,
@@ -71,6 +73,29 @@ export { compareVersions, normalizeVersion };
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`;
/** Public roots shared with the stable installer entry point. */
export const OSS_ORIGIN = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com";
export const OSS_RELEASE_ROOT = `${OSS_ORIGIN}/releases`;
export const GITHUB_RELEASE_ROOT = `https://github.com/${REPO_SLUG}/releases/download`;
export type DownloadSource = "auto" | "oss" | "github";
export type ReleaseDiscovery = "pinned" | "oss" | "github";
export interface ResolvedRelease {
version: string;
tag: string;
discoveredFrom: ReleaseDiscovery;
}
export interface InstallerCandidate {
source: InstallerSource;
baseUrl: string;
url: string;
/** Same-tag payload fallback passed to install.sh after this candidate is selected. */
fallbackBaseUrl?: string;
}
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
/** How this copy of the CLI was installed, which decides how it can be upgraded. */
export type InstallKind = "tarball" | "npm" | "source" | "unknown";
@@ -189,6 +214,8 @@ export function buildInstallerInvocation(opts: {
hasBundledNode: boolean;
defaultInstallDir: string;
version?: string;
downloadBaseUrl?: string;
downloadFallbackBaseUrl?: string;
}): { args: string[]; env: Record<string, string> } {
const args = [opts.scriptPath];
if (!opts.hasBundledNode) args.push("--universal");
@@ -197,15 +224,68 @@ export function buildInstallerInvocation(opts: {
env.PENGUIN_INSTALL_DIR = opts.installDir;
}
if (opts.version) env.PENGUIN_VERSION = `v${normalizeVersion(opts.version)}`;
if (opts.downloadBaseUrl !== undefined) env.PENGUIN_DOWNLOAD_BASE_URL = opts.downloadBaseUrl;
if (opts.downloadFallbackBaseUrl !== undefined)
env.PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = opts.downloadFallbackBaseUrl;
return { args, env };
}
/** Download URL for the installer of a given release (latest when no version is pinned). */
/** GitHub 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`;
? `${GITHUB_RELEASE_ROOT}/v${normalizeVersion(version)}/install.sh`
: `https://github.com/${REPO_SLUG}/releases/latest/download/install.sh`;
}
/** Normalizes the environment contract without silently accepting misspellings. */
export function parseDownloadSource(value: string | undefined): DownloadSource | null {
const source = value || "auto";
return source === "auto" || source === "oss" || source === "github" ? source : null;
}
/** Accepts only absolute HTTPS bases before any remote installer code is downloaded. */
export function normalizeHttpsBaseUrl(value: string | undefined): string | null {
if (!value) return null;
const normalized = value.replace(/\/+$/, "");
try {
const url = new URL(normalized);
return url.protocol === "https:" && url.hostname ? normalized : null;
} catch {
return null;
}
}
/** Explicit mirror settings outrank OSS/GitHub selection, matching the public forwarder. */
export function configuredInstallerCandidate(
baseUrl: string,
fallbackBaseUrl?: string,
): InstallerCandidate {
return {
source: "configured",
baseUrl,
url: `${baseUrl}/install.sh`,
...(fallbackBaseUrl ? { fallbackBaseUrl } : {}),
};
}
function isReleaseTag(value: string): boolean {
return /^v[0-9A-Za-z][0-9A-Za-z._-]*$/.test(value);
}
/** Validates OSS latest.json exactly like the public forwarders, including its fixed bucket base. */
export function parseOssLatestManifest(value: unknown): ResolvedRelease | null {
if (typeof value !== "object" || value === null) return null;
const manifest = value as {
schemaVersion?: unknown;
tag?: unknown;
releaseBaseUrl?: unknown;
};
if (manifest.schemaVersion !== 1 || typeof manifest.tag !== "string") return null;
if (!isReleaseTag(manifest.tag)) return null;
if (manifest.releaseBaseUrl !== `${OSS_RELEASE_ROOT}/${manifest.tag}`) return null;
const version = normalizeVersion(manifest.tag);
if (!version) return null;
return { version, tag: manifest.tag, discoveredFrom: "oss" };
}
/**
@@ -214,10 +294,10 @@ export function installerUrl(version?: string): string {
* 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> {
export async function fetchLatestVersion(t: Messages, fetcher: FetchLike = fetch): Promise<string> {
let res: Response;
try {
res = await fetch(LATEST_RELEASE_API, {
res = await fetcher(LATEST_RELEASE_API, {
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-cli" },
signal: AbortSignal.timeout(15_000),
});
@@ -237,6 +317,93 @@ export async function fetchLatestVersion(t: Messages): Promise<string> {
return normalizeVersion(tag);
}
/** Resolves and validates the OSS latest pointer; callers decide whether failure is strict. */
export async function fetchOssLatestRelease(
t: Messages,
fetcher: FetchLike = fetch,
): Promise<ResolvedRelease> {
try {
const res = await fetcher(`${OSS_ORIGIN}/latest.json`, {
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) throw new Error(String(res.status));
const release = parseOssLatestManifest(await res.json());
if (!release) throw new Error("invalid manifest");
return release;
} catch {
throw new Error(t.update.ossUnavailable());
}
}
/** Resolves one immutable target tag before planning or downloading anything. */
export async function resolveRelease(
source: DownloadSource,
requestedRelease: string | undefined,
t: Messages,
fetcher: FetchLike = fetch,
): Promise<ResolvedRelease> {
if (requestedRelease) {
const version = normalizeVersion(requestedRelease);
return { version, tag: `v${version}`, discoveredFrom: "pinned" };
}
if (source !== "github") {
try {
return await fetchOssLatestRelease(t, fetcher);
} catch (error) {
if (source === "oss") throw error;
}
}
const version = await fetchLatestVersion(t, fetcher);
return { version, tag: `v${version}`, discoveredFrom: "github" };
}
/**
* Produces immutable, same-tag installer candidates. If auto had to discover the target through
* GitHub because OSS metadata was unavailable, it follows the forwarder and stays on GitHub.
*/
export function installerCandidates(
source: DownloadSource,
release: ResolvedRelease,
): InstallerCandidate[] {
const githubBase = `${GITHUB_RELEASE_ROOT}/${release.tag}`;
const github: InstallerCandidate = {
source: "github",
baseUrl: githubBase,
url: `${githubBase}/install.sh`,
};
if (source === "github" || (source === "auto" && release.discoveredFrom === "github")) {
return [github];
}
const ossBase = `${OSS_RELEASE_ROOT}/${release.tag}`;
const oss: InstallerCandidate = {
source: "oss",
baseUrl: ossBase,
url: `${ossBase}/install.sh`,
...(source === "auto" ? { fallbackBaseUrl: githubBase } : {}),
};
return source === "auto" ? [oss, github] : [oss];
}
/** Downloads fully before execution; transport failure advances only to the next same-tag source. */
export async function downloadInstaller(
candidates: InstallerCandidate[],
fetcher: FetchLike = fetch,
): Promise<{ script: string; candidate: InstallerCandidate } | null> {
for (const candidate of candidates) {
try {
const res = await fetcher(candidate.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) continue;
return { script: await res.text(), candidate };
} catch {
// Try the next same-version candidate, if one was configured.
}
}
return null;
}
/** 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 });
@@ -370,7 +537,10 @@ export function registerUpdateCommand(program: Command, t: Messages): void {
.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 source = parseDownloadSource(process.env.PENGUIN_DOWNLOAD_SOURCE);
if (!source) throw new Error(t.update.invalidDownloadSource());
const release = await resolveRelease(source, opts.release, t);
const target = release.version;
const modulePath = selfPath();
const defaultInstallDir = path.join(homedir(), ".penguin");
const plan = planUpdate({
@@ -419,14 +589,28 @@ export function registerUpdateCommand(program: Command, t: Messages): void {
);
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`);
const explicitBaseValue = process.env.PENGUIN_DOWNLOAD_BASE_URL;
const explicitFallbackValue = process.env.PENGUIN_DOWNLOAD_FALLBACK_BASE_URL;
let candidates: InstallerCandidate[];
if (explicitBaseValue) {
const explicitBase = normalizeHttpsBaseUrl(explicitBaseValue);
if (!explicitBase)
throw new Error(t.update.downloadBaseMustBeHttps("PENGUIN_DOWNLOAD_BASE_URL"));
let explicitFallback: string | undefined;
if (explicitFallbackValue) {
const normalizedFallback = normalizeHttpsBaseUrl(explicitFallbackValue);
if (!normalizedFallback)
throw new Error(t.update.downloadBaseMustBeHttps("PENGUIN_DOWNLOAD_FALLBACK_BASE_URL"));
explicitFallback = normalizedFallback;
}
candidates = [configuredInstallerCandidate(explicitBase, explicitFallback)];
} else {
candidates = installerCandidates(source, release);
}
const downloaded = await downloadInstaller(candidates);
if (!downloaded) {
const sources = candidates.map((candidate) => candidate.source);
process.stdout.write(`${t.update.installerFetchFailed(sources)}\n`);
process.exitCode = 1;
return;
}
@@ -439,14 +623,17 @@ export function registerUpdateCommand(program: Command, t: Messages): void {
// 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" });
writeFileSync(scriptPath, downloaded.script, { mode: 0o700, flag: "wx" });
const { args, env } = buildInstallerInvocation({
scriptPath,
installDir,
hasBundledNode,
defaultInstallDir,
version: opts.release ? target : undefined,
version: target,
downloadBaseUrl: downloaded.candidate.baseUrl,
// Always override the inherited environment: an absent fallback must clear a stale one.
downloadFallbackBaseUrl: downloaded.candidate.fallbackBaseUrl ?? "",
});
// 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.
+33 -3
View File
@@ -9,6 +9,9 @@
/** UI language. */
export type Language = "en" | "zh";
/** Installer locations are localized at the message boundary, not embedded in update logic. */
export type InstallerSource = "configured" | "oss" | "github";
/** Readiness probe failure classes; selects which hint `webProbeFailed` appends. */
export type WebProbeFailureKind =
"timeout" | "refused" | "reset" | "permission" | "dns" | "unknown";
@@ -119,7 +122,10 @@ export interface Messages {
rateLimited(): string;
apiFailed(status: number): string;
apiMalformed(): string;
installerFetchFailed(url: string): string;
invalidDownloadSource(): string;
downloadBaseMustBeHttps(name: string): string;
ossUnavailable(): string;
installerFetchFailed(sources: InstallerSource[]): string;
};
// —— Runtime output ——
@@ -384,7 +390,19 @@ const en: Messages = {
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}.`,
invalidDownloadSource: () => "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github.",
downloadBaseMustBeHttps: (name) => `${name} must be an absolute HTTPS URL.`,
ossUnavailable: () => "The OSS mirror is unavailable or its release metadata is invalid.",
installerFetchFailed: (sources) =>
`Could not download the installer from ${sources
.map((source) =>
source === "configured"
? "the configured mirror"
: source === "oss"
? "the OSS mirror"
: "GitHub",
)
.join(" or ")}. Check your network and retry.`,
},
header: headerEn,
@@ -598,7 +616,19 @@ const zh: Messages = {
"GitHub 对版本查询做了限流。请等待几分钟后重试,或用 --release <tag> 跳过查询。",
apiFailed: (status) => `GitHub 版本查询失败,HTTP ${status}。`,
apiMalformed: () => "GitHub 版本查询返回了非预期的响应,其中没有可用的版本号。",
installerFetchFailed: (url) => `无法从 ${url} 下载安装脚本。`,
invalidDownloadSource: () => "PENGUIN_DOWNLOAD_SOURCE 必须是 auto、oss 或 github。",
downloadBaseMustBeHttps: (name) => `${name} 必须是绝对 HTTPS URL。`,
ossUnavailable: () => "OSS 镜像不可用,或其版本元数据无效。",
installerFetchFailed: (sources) => {
const sourceText = sources
.map((source) =>
source === "configured" ? "配置的镜像" : source === "oss" ? "OSS 镜像" : "GitHub",
)
.join("或 ");
const leadingSpace = /^[A-Za-z]/.test(sourceText) ? " " : "";
const trailingSpace = /[A-Za-z]$/.test(sourceText) ? " " : "";
return `无法从${leadingSpace}${sourceText}${trailingSpace}下载安装脚本。请检查网络后重试。`;
},
},
header: headerZh,
+230 -1
View File
@@ -4,7 +4,7 @@
* 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.
* No real network and no filesystem mutation — every I/O helper takes its inputs as arguments.
*/
import { describe, expect, it } from "vitest";
import { Command } from "commander";
@@ -12,13 +12,20 @@ import {
buildInstallerInvocation,
compareVersions,
confirmationMode,
configuredInstallerCandidate,
detectInstall,
detectPackageManager,
downloadInstaller,
globalInstallCommand,
installerCandidates,
installerUrl,
normalizeVersion,
normalizeHttpsBaseUrl,
parseDownloadSource,
parseOssLatestManifest,
planUpdate,
registerUpdateCommand,
resolveRelease,
} from "../src/commands/update.js";
import { getMessages } from "../src/i18n.js";
@@ -175,6 +182,171 @@ describe("installerUrl", () => {
});
});
describe("release source selection", () => {
const ossOrigin = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com";
const githubApi = "https://api.github.com/repos/Prism-Shadow/penguin-harness/releases/latest";
const manifest = {
schemaVersion: 1,
tag: "v0.2.1",
version: "0.2.1",
releaseBaseUrl: `${ossOrigin}/releases/v0.2.1`,
};
const t = getMessages("en");
it("accepts the same auto/oss/github environment contract as the installers", () => {
expect(parseDownloadSource(undefined)).toBe("auto");
expect(parseDownloadSource("auto")).toBe("auto");
expect(parseDownloadSource("oss")).toBe("oss");
expect(parseDownloadSource("github")).toBe("github");
expect(parseDownloadSource("OSS")).toBeNull();
expect(parseDownloadSource("mirror")).toBeNull();
});
it("accepts only absolute HTTPS mirror bases and removes trailing slashes", () => {
expect(normalizeHttpsBaseUrl("https://mirror.example/releases/v0.2.1/")).toBe(
"https://mirror.example/releases/v0.2.1",
);
expect(normalizeHttpsBaseUrl("http://mirror.example/releases/v0.2.1")).toBeNull();
expect(normalizeHttpsBaseUrl("/releases/v0.2.1")).toBeNull();
expect(normalizeHttpsBaseUrl(undefined)).toBeNull();
});
it("an explicit mirror is one strict installer candidate with its configured fallback", () => {
expect(
configuredInstallerCandidate(
"https://mirror.example/releases/v0.2.1",
"https://backup.example/releases/v0.2.1",
),
).toEqual({
source: "configured",
baseUrl: "https://mirror.example/releases/v0.2.1",
url: "https://mirror.example/releases/v0.2.1/install.sh",
fallbackBaseUrl: "https://backup.example/releases/v0.2.1",
});
});
it("validates latest.json's schema, tag, and fixed OSS release base", () => {
expect(parseOssLatestManifest(manifest)).toEqual({
version: "0.2.1",
tag: "v0.2.1",
discoveredFrom: "oss",
});
expect(parseOssLatestManifest({ ...manifest, schemaVersion: 2 })).toBeNull();
expect(parseOssLatestManifest({ ...manifest, tag: "../bad" })).toBeNull();
expect(
parseOssLatestManifest({ ...manifest, releaseBaseUrl: "https://example.com/v0.2.1" }),
).toBeNull();
});
it("auto discovers latest through OSS without touching GitHub when metadata is valid", async () => {
const calls: string[] = [];
const fetcher = async (url: string) => {
calls.push(url);
return new Response(JSON.stringify(manifest), { status: 200 });
};
await expect(resolveRelease("auto", undefined, t, fetcher)).resolves.toMatchObject({
tag: "v0.2.1",
discoveredFrom: "oss",
});
expect(calls).toEqual([`${ossOrigin}/latest.json`]);
});
it("auto falls back to GitHub discovery when OSS metadata is unavailable", async () => {
const calls: string[] = [];
const fetcher = async (url: string) => {
calls.push(url);
if (url === `${ossOrigin}/latest.json`) return new Response("unavailable", { status: 503 });
return new Response(JSON.stringify({ tag_name: "v0.2.2" }), { status: 200 });
};
await expect(resolveRelease("auto", undefined, t, fetcher)).resolves.toEqual({
version: "0.2.2",
tag: "v0.2.2",
discoveredFrom: "github",
});
expect(calls).toEqual([`${ossOrigin}/latest.json`, githubApi]);
});
it("forced oss is strict, while forced github skips OSS", async () => {
const ossCalls: string[] = [];
const unavailable = async (url: string) => {
ossCalls.push(url);
return new Response("unavailable", { status: 503 });
};
await expect(resolveRelease("oss", undefined, t, unavailable)).rejects.toThrow(
t.update.ossUnavailable(),
);
expect(ossCalls).toEqual([`${ossOrigin}/latest.json`]);
const githubCalls: string[] = [];
const github = async (url: string) => {
githubCalls.push(url);
return new Response(JSON.stringify({ tag_name: "v0.2.2" }), { status: 200 });
};
await expect(resolveRelease("github", undefined, t, github)).resolves.toMatchObject({
tag: "v0.2.2",
discoveredFrom: "github",
});
expect(githubCalls).toEqual([githubApi]);
});
it("a requested release skips discovery and produces same-tag source candidates", async () => {
let fetched = false;
const shouldNotFetch = async () => {
fetched = true;
throw new Error("unexpected fetch");
};
const release = await resolveRelease("auto", "0.2.0", t, shouldNotFetch);
expect(fetched).toBe(false);
expect(installerCandidates("auto", release)).toEqual([
{
source: "oss",
baseUrl: `${ossOrigin}/releases/v0.2.0`,
url: `${ossOrigin}/releases/v0.2.0/install.sh`,
fallbackBaseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
},
{
source: "github",
baseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
url: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0/install.sh",
},
]);
});
it("auto stays on GitHub when OSS latest discovery failed", () => {
const release = {
version: "0.2.2",
tag: "v0.2.2",
discoveredFrom: "github" as const,
};
expect(installerCandidates("auto", release).map((candidate) => candidate.source)).toEqual([
"github",
]);
expect(installerCandidates("github", release).map((candidate) => candidate.source)).toEqual([
"github",
]);
expect(installerCandidates("oss", release).map((candidate) => candidate.source)).toEqual([
"oss",
]);
});
it("installer transport failure falls back to the matching GitHub tag", async () => {
const release = parseOssLatestManifest(manifest);
expect(release).not.toBeNull();
const candidates = installerCandidates("auto", release!);
const calls: string[] = [];
const fetcher = async (url: string) => {
calls.push(url);
return url.includes("aliyuncs.com")
? new Response("unavailable", { status: 503 })
: new Response("#!/bin/sh\n", { status: 200 });
};
const downloaded = await downloadInstaller(candidates, fetcher);
expect(downloaded?.candidate.source).toBe("github");
expect(downloaded?.script).toBe("#!/bin/sh\n");
expect(calls).toEqual(candidates.map((candidate) => candidate.url));
});
});
describe("buildInstallerInvocation (preserves the shape of the install being upgraded)", () => {
const base = {
scriptPath: "/tmp/penguin-install-1.sh",
@@ -246,6 +418,63 @@ describe("buildInstallerInvocation (preserves the shape of the install being upg
env: { PENGUIN_INSTALL_DIR: "/opt/penguin", PENGUIN_VERSION: "v0.2.0" },
});
});
it("pins the selected payload source and same-version fallback for the child installer", () => {
expect(
buildInstallerInvocation({
...base,
installDir: "/home/me/.penguin",
hasBundledNode: true,
version: "0.2.0",
downloadBaseUrl:
"https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.2.0",
downloadFallbackBaseUrl:
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
}).env,
).toEqual({
PENGUIN_VERSION: "v0.2.0",
PENGUIN_DOWNLOAD_BASE_URL:
"https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.2.0",
PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
});
});
it("explicitly clears an inherited fallback when the selected source has none", () => {
expect(
buildInstallerInvocation({
...base,
installDir: "/home/me/.penguin",
hasBundledNode: true,
version: "0.2.0",
downloadBaseUrl: "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
downloadFallbackBaseUrl: "",
}).env,
).toEqual({
PENGUIN_VERSION: "v0.2.0",
PENGUIN_DOWNLOAD_BASE_URL:
"https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.2.0",
PENGUIN_DOWNLOAD_FALLBACK_BASE_URL: "",
});
});
it("localizes the source list and connector in installer download failures", () => {
expect(getMessages("en").update.installerFetchFailed(["oss", "github"])).toBe(
"Could not download the installer from the OSS mirror or GitHub. Check your network and retry.",
);
expect(getMessages("zh").update.installerFetchFailed(["oss"])).toBe(
"无法从 OSS 镜像下载安装脚本。请检查网络后重试。",
);
expect(getMessages("zh").update.installerFetchFailed(["github"])).toBe(
"无法从 GitHub 下载安装脚本。请检查网络后重试。",
);
expect(getMessages("zh").update.installerFetchFailed(["oss", "github"])).toBe(
"无法从 OSS 镜像或 GitHub 下载安装脚本。请检查网络后重试。",
);
expect(getMessages("zh").update.installerFetchFailed(["configured"])).toBe(
"无法从配置的镜像下载安装脚本。请检查网络后重试。",
);
});
});
describe("planUpdate (what the command decides before it touches anything)", () => {
+3 -1
View File
@@ -161,12 +161,14 @@ penguin update # upgrade to the latest release, after confirming
The target flag is `--release`, not `--version`, because `-v, --version` is the CLI's own version flag and would take precedence.
Release discovery and tarball downloads honor `PENGUIN_DOWNLOAD_SOURCE=auto|oss|github`, using the same policy as the stable installer entry point. The default `auto` mode reads the OSS `latest.json`, prefers that immutable release, and falls back to the matching GitHub tag. Forced `oss` and `github` modes are strict; `--release <tag>` skips latest-version discovery while retaining the selected source policy. An explicit HTTPS `PENGUIN_DOWNLOAD_BASE_URL` has highest priority for installer and payload downloads, with an optional `PENGUIN_DOWNLOAD_FALLBACK_BASE_URL` for the payload.
| Install kind | How it upgrades |
| --- | --- |
| Tarball (`install.sh`, default `~/.penguin`) | Re-runs the official installer, preserving the install dir and whether the package bundles a Node runtime |
| Global npm/pnpm/yarn/bun install | Runs that manager's global install of `@prismshadow/penguin-cli@<target>`; if the manager cannot be identified, prints the command instead of guessing |
| Source checkout | Refused — update it with `git pull` and a rebuild |
Without `-y` the command prints exactly what it will do — mechanism, target version and install dir — and asks for confirmation; when stdin is not a terminal it requires `--yes` rather than waiting on a prompt nobody can answer. The latest version comes from the GitHub Releases API. **The data root is never touched**: an upgrade only replaces `bin`, `lib`, `web` and `node`. Neither path upgrades in place on Windows: the installer is a POSIX shell script, and a global install cannot be driven from here because Node will not execute an `npm`/`pnpm` `.cmd` shim without a shell — so the command prints the exact command to run yourself instead.
Without `-y` the command prints exactly what it will do — mechanism, target version and install dir — and asks for confirmation; when stdin is not a terminal it requires `--yes` rather than waiting on a prompt nobody can answer. **The data root is never touched**: an upgrade only replaces `bin`, `lib`, `web` and `node`. Neither path upgrades in place on Windows: the installer is a POSIX shell script, and a global install cannot be driven from here because Node will not execute an `npm`/`pnpm` `.cmd` shim without a shell — so the command prints the exact command to run yourself instead.
See also: [Configuration Reference](/configuration), [Models & Providers](/models).
+3 -1
View File
@@ -161,12 +161,14 @@ penguin update # 确认后升级到最新版
目标版本参数叫 `--release` 而不是 `--version`,因为 `-v, --version` 是 CLI 自身的版本参数,会优先生效。
版本发现和 tarball 下载遵循 `PENGUIN_DOWNLOAD_SOURCE=auto|oss|github`,与稳定安装入口使用相同策略。默认的 `auto` 模式读取 OSS `latest.json`,优先选择该不可变版本,并按同一 tag 回退到 GitHub;强制 `oss` 或 `github` 时不会切换来源。`--release <tag>` 会跳过最新版查询,但仍遵循所选下载源策略。显式设置的 HTTPS `PENGUIN_DOWNLOAD_BASE_URL` 对安装脚本和发布包下载具有最高优先级,并可通过 `PENGUIN_DOWNLOAD_FALLBACK_BASE_URL` 为发布包配置后备地址。
| 安装方式 | 升级方式 |
| --- | --- |
| tarball(`install.sh`,默认 `~/.penguin`) | 重新执行官方安装脚本,并保持原安装目录以及是否内置 Node 运行时 |
| npm/pnpm/yarn/bun 全局安装 | 用该包管理器全局安装 `@prismshadow/penguin-cli@<目标版本>`;无法确定包管理器时,只打印命令而不猜测 |
| 源码检出 | 拒绝执行——请用 `git pull` 更新并重新构建 |
不带 `-y` 时,命令会先打印它将要做什么——方式、目标版本与安装目录——再请求确认;当 stdin 不是终端时,它要求显式加 `--yes`,而不是卡在无人能回答的提示上。最新版本取自 GitHub Releases API。**数据目录不会被改动**:升级只替换 `bin`、`lib`、`web` 与 `node`。两条路径在 Windows 上都不做原地升级:安装脚本是 POSIX shell 脚本,而全局安装也无法由此驱动——Node 不会在没有 shell 的情况下执行 `npm`/`pnpm` 的 `.cmd` 包装脚本——因此命令会直接打印出应当由你自己执行的命令。
不带 `-y` 时,命令会先打印它将要做什么——方式、目标版本与安装目录——再请求确认;当 stdin 不是终端时,它要求显式加 `--yes`,而不是卡在无人能回答的提示上。**数据目录不会被改动**:升级只替换 `bin`、`lib`、`web` 与 `node`。两条路径在 Windows 上都不做原地升级:安装脚本是 POSIX shell 脚本,而全局安装也无法由此驱动——Node 不会在没有 shell 的情况下执行 `npm`/`pnpm` 的 `.cmd` 包装脚本——因此命令会直接打印出应当由你自己执行的命令。
相关文档:[配置参考](/configuration)、[模型与 Provider](/models)。