From 8362eca6e55e86843f7fe44771e60156e86dd392 Mon Sep 17 00:00:00 2001 From: Laodouuu <1257029425@qq.com> Date: Tue, 4 Aug 2026 23:13:44 +0800 Subject: [PATCH] feat(cli): add update download source selection (#196) --- packages/cli/src/commands/update.ts | 235 +++++++++++++++++++++++++--- packages/cli/src/i18n.ts | 36 ++++- packages/cli/test/update.test.ts | 231 ++++++++++++++++++++++++++- packages/docs/content/cli.en.md | 4 +- packages/docs/content/cli.zh.md | 4 +- 5 files changed, 480 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index ce00498..525f4f5 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -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 ` rather than `--version `: 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 ` rather than `--version `: 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; /** 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 } { 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 { +export async function fetchLatestVersion(t: Messages, fetcher: FetchLike = fetch): Promise { 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 { 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 { + 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 { + 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 { 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. diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index ed72e99..042ee5e 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -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 跳过查询。", 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, diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts index 2fef819..718f9f7 100644 --- a/packages/cli/test/update.test.ts +++ b/packages/cli/test/update.test.ts @@ -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)", () => { diff --git a/packages/docs/content/cli.en.md b/packages/docs/content/cli.en.md index 55be581..8b1c045 100644 --- a/packages/docs/content/cli.en.md +++ b/packages/docs/content/cli.en.md @@ -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 ` 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@`; 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). diff --git a/packages/docs/content/cli.zh.md b/packages/docs/content/cli.zh.md index 0b4deac..0d3ac32 100644 --- a/packages/docs/content/cli.zh.md +++ b/packages/docs/content/cli.zh.md @@ -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 ` 会跳过最新版查询,但仍遵循所选下载源策略。显式设置的 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)。