feat(cli): add update download source selection (#196)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)", () => {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
Reference in New Issue
Block a user