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 * made — never by guessing. A source checkout is refused outright: overwriting a working tree
* would destroy uncommitted work. * would destroy uncommitted work.
* *
* The latest version comes from the GitHub Releases API, the same source of truth install.sh * Release discovery and installer download use the same environment contract as the public
* resolves `releases/latest/download` against. The target-a-specific-release flag is spelled * installer entry point: an explicit PENGUIN_DOWNLOAD_BASE_URL has highest download priority;
* `--release <tag>` rather than `--version <tag>`: commander's program-level `-v, --version` * otherwise auto prefers the OSS latest pointer and immutable release, then falls back to the same
* intercepts a subcommand's own `--version` when it is written with a space, so * GitHub tag, while oss and github are strict. The target-a-specific-release flag is spelled
* `penguin update --version 0.1.2` would silently print the CLI version and do nothing. A flag * `--release <tag>` rather than `--version <tag>`: commander's program-level
* that works only in its `--version=0.1.2` form is a trap, so it got an unambiguous name. * `-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 * 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 * 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 { realpathSync } from "node:fs";
import { VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core"; import { VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
import type { Command } from "commander"; 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 // 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, // 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"; export const REPO_SLUG = "Prism-Shadow/penguin-harness";
/** Releases API endpoint for the newest published release. */ /** Releases API endpoint for the newest published release. */
export const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`; 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. */ /** How this copy of the CLI was installed, which decides how it can be upgraded. */
export type InstallKind = "tarball" | "npm" | "source" | "unknown"; export type InstallKind = "tarball" | "npm" | "source" | "unknown";
@@ -189,6 +214,8 @@ export function buildInstallerInvocation(opts: {
hasBundledNode: boolean; hasBundledNode: boolean;
defaultInstallDir: string; defaultInstallDir: string;
version?: string; version?: string;
downloadBaseUrl?: string;
downloadFallbackBaseUrl?: string;
}): { args: string[]; env: Record<string, string> } { }): { args: string[]; env: Record<string, string> } {
const args = [opts.scriptPath]; const args = [opts.scriptPath];
if (!opts.hasBundledNode) args.push("--universal"); if (!opts.hasBundledNode) args.push("--universal");
@@ -197,15 +224,68 @@ export function buildInstallerInvocation(opts: {
env.PENGUIN_INSTALL_DIR = opts.installDir; env.PENGUIN_INSTALL_DIR = opts.installDir;
} }
if (opts.version) env.PENGUIN_VERSION = `v${normalizeVersion(opts.version)}`; 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 }; 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 { export function installerUrl(version?: string): string {
const base = `https://github.com/${REPO_SLUG}/releases`;
return version return version
? `${base}/download/v${normalizeVersion(version)}/install.sh` ? `${GITHUB_RELEASE_ROOT}/v${normalizeVersion(version)}/install.sh`
: `${base}/latest/download/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 * arrives with no useful body from an unauthenticated client), any other HTTP status, and a body
* that parses but carries no usable `tag_name`. * 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; let res: Response;
try { try {
res = await fetch(LATEST_RELEASE_API, { res = await fetcher(LATEST_RELEASE_API, {
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-cli" }, headers: { accept: "application/vnd.github+json", "user-agent": "penguin-cli" },
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}); });
@@ -237,6 +317,93 @@ export async function fetchLatestVersion(t: Messages): Promise<string> {
return normalizeVersion(tag); 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. */ /** Interactive y/N confirmation; SIGINT and stream close both count as "no", so it can never hang. */
function confirmYes(prompt: string): Promise<boolean> { function confirmYes(prompt: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); 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) .option("-y, --yes", t.update.yes)
.action(async (opts: { check?: boolean; release?: string; yes?: boolean }) => { .action(async (opts: { check?: boolean; release?: string; yes?: boolean }) => {
const current = VERSION; 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 modulePath = selfPath();
const defaultInstallDir = path.join(homedir(), ".penguin"); const defaultInstallDir = path.join(homedir(), ".penguin");
const plan = planUpdate({ const plan = planUpdate({
@@ -419,14 +589,28 @@ export function registerUpdateCommand(program: Command, t: Messages): void {
); );
if (!(await confirmUpgrade(opts.yes, t))) return; if (!(await confirmUpgrade(opts.yes, t))) return;
const url = installerUrl(opts.release ? target : undefined); const explicitBaseValue = process.env.PENGUIN_DOWNLOAD_BASE_URL;
let script: string; const explicitFallbackValue = process.env.PENGUIN_DOWNLOAD_FALLBACK_BASE_URL;
try { let candidates: InstallerCandidate[];
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (explicitBaseValue) {
if (!res.ok) throw new Error(String(res.status)); const explicitBase = normalizeHttpsBaseUrl(explicitBaseValue);
script = await res.text(); if (!explicitBase)
} catch { throw new Error(t.update.downloadBaseMustBeHttps("PENGUIN_DOWNLOAD_BASE_URL"));
process.stdout.write(`${t.update.installerFetchFailed(url)}\n`); 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; process.exitCode = 1;
return; return;
} }
@@ -439,14 +623,17 @@ export function registerUpdateCommand(program: Command, t: Messages): void {
// not something to rely on. // not something to rely on.
const scriptDir = mkdtempSync(path.join(tmpdir(), "penguin-update-")); const scriptDir = mkdtempSync(path.join(tmpdir(), "penguin-update-"));
const scriptPath = path.join(scriptDir, "install.sh"); 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({ const { args, env } = buildInstallerInvocation({
scriptPath, scriptPath,
installDir, installDir,
hasBundledNode, hasBundledNode,
defaultInstallDir, 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 // 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. // 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. */ /** UI language. */
export type Language = "en" | "zh"; 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. */ /** Readiness probe failure classes; selects which hint `webProbeFailed` appends. */
export type WebProbeFailureKind = export type WebProbeFailureKind =
"timeout" | "refused" | "reset" | "permission" | "dns" | "unknown"; "timeout" | "refused" | "reset" | "permission" | "dns" | "unknown";
@@ -119,7 +122,10 @@ export interface Messages {
rateLimited(): string; rateLimited(): string;
apiFailed(status: number): string; apiFailed(status: number): string;
apiMalformed(): string; apiMalformed(): string;
installerFetchFailed(url: string): string; invalidDownloadSource(): string;
downloadBaseMustBeHttps(name: string): string;
ossUnavailable(): string;
installerFetchFailed(sources: InstallerSource[]): string;
}; };
// —— Runtime output —— // —— Runtime output ——
@@ -384,7 +390,19 @@ const en: Messages = {
apiFailed: (status) => `The GitHub release lookup failed with HTTP ${status}.`, apiFailed: (status) => `The GitHub release lookup failed with HTTP ${status}.`,
apiMalformed: () => apiMalformed: () =>
"The GitHub release lookup returned an unexpected response with no usable version tag.", "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, header: headerEn,
@@ -598,7 +616,19 @@ const zh: Messages = {
"GitHub 对版本查询做了限流。请等待几分钟后重试,或用 --release <tag> 跳过查询。", "GitHub 对版本查询做了限流。请等待几分钟后重试,或用 --release <tag> 跳过查询。",
apiFailed: (status) => `GitHub 版本查询失败,HTTP ${status}。`, apiFailed: (status) => `GitHub 版本查询失败,HTTP ${status}。`,
apiMalformed: () => "GitHub 版本查询返回了非预期的响应,其中没有可用的版本号。", 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, 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 * 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. * 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 { describe, expect, it } from "vitest";
import { Command } from "commander"; import { Command } from "commander";
@@ -12,13 +12,20 @@ import {
buildInstallerInvocation, buildInstallerInvocation,
compareVersions, compareVersions,
confirmationMode, confirmationMode,
configuredInstallerCandidate,
detectInstall, detectInstall,
detectPackageManager, detectPackageManager,
downloadInstaller,
globalInstallCommand, globalInstallCommand,
installerCandidates,
installerUrl, installerUrl,
normalizeVersion, normalizeVersion,
normalizeHttpsBaseUrl,
parseDownloadSource,
parseOssLatestManifest,
planUpdate, planUpdate,
registerUpdateCommand, registerUpdateCommand,
resolveRelease,
} from "../src/commands/update.js"; } from "../src/commands/update.js";
import { getMessages } from "../src/i18n.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)", () => { describe("buildInstallerInvocation (preserves the shape of the install being upgraded)", () => {
const base = { const base = {
scriptPath: "/tmp/penguin-install-1.sh", 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" }, 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)", () => { 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. 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 | | 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 | | 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 | | 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 | | 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). See also: [Configuration Reference](/configuration), [Models & Providers](/models).
+3 -1
View File
@@ -161,12 +161,14 @@ penguin update # 确认后升级到最新版
目标版本参数叫 `--release` 而不是 `--version`,因为 `-v, --version` 是 CLI 自身的版本参数,会优先生效。 目标版本参数叫 `--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 运行时 | | tarball(`install.sh`,默认 `~/.penguin`) | 重新执行官方安装脚本,并保持原安装目录以及是否内置 Node 运行时 |
| npm/pnpm/yarn/bun 全局安装 | 用该包管理器全局安装 `@prismshadow/penguin-cli@<目标版本>`;无法确定包管理器时,只打印命令而不猜测 | | npm/pnpm/yarn/bun 全局安装 | 用该包管理器全局安装 `@prismshadow/penguin-cli@<目标版本>`;无法确定包管理器时,只打印命令而不猜测 |
| 源码检出 | 拒绝执行——请用 `git pull` 更新并重新构建 | | 源码检出 | 拒绝执行——请用 `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)。 相关文档:[配置参考](/configuration)、[模型与 Provider](/models)。