Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 34 additions & 34 deletions dist/index.mjs

Large diffs are not rendered by default.

50 changes: 47 additions & 3 deletions src/install-viteplus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ describe("installVitePlus", () => {
expect(warning).toHaveBeenCalledTimes(2);
});

it("should throw after exhausting all retries", async () => {
it("should throw after exhausting all rounds across both URLs", async () => {
vi.mocked(exec).mockResolvedValue(6);

await expect(installVitePlus(baseInputs)).rejects.toThrow(/after 3 attempts/);
expect(exec).toHaveBeenCalledTimes(3);
await expect(installVitePlus(baseInputs)).rejects.toThrow(/after 4 attempts across 2 URL\(s\)/);
// 2 rounds × 2 URLs = 4 attempts.
expect(exec).toHaveBeenCalledTimes(4);
});

it("should retry when exec itself throws (e.g. process spawn error)", async () => {
Expand All @@ -68,4 +69,47 @@ describe("installVitePlus", () => {
expect(exec).toHaveBeenCalledTimes(2);
expect(warning).toHaveBeenCalledTimes(1);
});

it("should fall back to the GitHub install URL after a single primary failure", async () => {
vi.mocked(exec).mockResolvedValueOnce(35).mockResolvedValueOnce(0);

await installVitePlus(baseInputs);

expect(exec).toHaveBeenCalledTimes(2);

const primaryScript = (vi.mocked(exec).mock.calls[0][1] as string[])[1];
expect(primaryScript).toContain("https://viteplus.dev/install.sh");

const fallbackScript = (vi.mocked(exec).mock.calls[1][1] as string[])[1];
expect(fallbackScript).toContain(
"https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh",
);
});

it("should alternate primary and fallback URLs across rounds", async () => {
vi.mocked(exec).mockResolvedValue(35);

await expect(installVitePlus(baseInputs)).rejects.toThrow();

const scripts = vi.mocked(exec).mock.calls.map((call) => (call[1] as string[])[1]);
expect(scripts).toHaveLength(4);
expect(scripts[0]).toContain("viteplus.dev/install.sh");
expect(scripts[1]).toContain("raw.githubusercontent.com");
expect(scripts[2]).toContain("viteplus.dev/install.sh");
expect(scripts[3]).toContain("raw.githubusercontent.com");
});

it("should run the bash install with pipefail and timeout flags so transient failures fail fast", async () => {
vi.mocked(exec).mockResolvedValueOnce(0);

await installVitePlus(baseInputs);

const [cmd, args] = vi.mocked(exec).mock.calls[0];
expect(cmd).toBe("bash");
const script = (args as string[])[1];
expect(script).toMatch(/^set -o pipefail;/);
expect(script).toContain("--connect-timeout");
expect(script).toContain("--max-time");
expect(script).toMatch(/\| bash$/);
Comment thread
fengmk2 marked this conversation as resolved.
Comment thread
fengmk2 marked this conversation as resolved.
});
});
69 changes: 46 additions & 23 deletions src/install-viteplus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,24 @@ import type { Inputs } from "./types.js";
import { DISPLAY_NAME } from "./types.js";
import { getVitePlusHome } from "./utils.js";

const INSTALL_URL_SH = "https://viteplus.dev/install.sh";
const INSTALL_URL_PS1 = "https://viteplus.dev/install.ps1";
const INSTALL_MAX_ATTEMPTS = 3;
// Try the CDN first, then fall back to the install scripts in the vite-plus
// repo so a CDN/edge incident doesn't fully block CI.
const INSTALL_URLS_SH = [
"https://viteplus.dev/install.sh",
"https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh",
];
const INSTALL_URLS_PS1 = [
"https://viteplus.dev/install.ps1",
"https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1",
];
// Alternate primary/fallback for up to N rounds (max attempts = rounds * URLs).
// Two rounds × two URLs = 4 attempts, ~1 minute worst case.
const INSTALL_MAX_ROUNDS = 2;
const INSTALL_RETRY_DELAY_MS = 2000;
// Cap each network call so a hung connection fails fast (failing runs showed
// ~30s default hangs); the outer loop then immediately tries the next URL.
const CURL_TIMEOUT_FLAGS = "--connect-timeout 5 --max-time 15";
const PWSH_TIMEOUT_SEC = 15;

export async function installVitePlus(inputs: Inputs): Promise<void> {
const { version } = inputs;
Expand All @@ -24,43 +38,52 @@ export async function installVitePlus(inputs: Inputs): Promise<void> {
VITE_PLUS_VERSION: version,
} as { [key: string]: string };

const urls = process.platform === "win32" ? INSTALL_URLS_PS1 : INSTALL_URLS_SH;
const maxAttempts = INSTALL_MAX_ROUNDS * urls.length;
let failureReason = "";
for (let attempt = 1; attempt <= INSTALL_MAX_ATTEMPTS; attempt++) {
try {
const exitCode = await runInstallCommand(env);
if (exitCode === 0) {
ensureVitePlusBinInPath();
return;
let attempt = 0;
for (let round = 0; round < INSTALL_MAX_ROUNDS; round++) {
for (const url of urls) {
attempt++;
try {
const exitCode = await runInstallCommand(url, env);
if (exitCode === 0) {
ensureVitePlusBinInPath();
return;
}
failureReason = `exit code ${exitCode}`;
} catch (error) {
failureReason = error instanceof Error ? error.message : String(error);
}
failureReason = `exit code ${exitCode}`;
} catch (error) {
failureReason = error instanceof Error ? error.message : String(error);
}

if (attempt < INSTALL_MAX_ATTEMPTS) {
const delay = INSTALL_RETRY_DELAY_MS * attempt;
warning(
`Failed to install ${DISPLAY_NAME} (${failureReason}). Retrying in ${delay}ms... (attempt ${attempt + 1}/${INSTALL_MAX_ATTEMPTS})`,
);
await sleep(delay);
if (attempt < maxAttempts) {
warning(
`Failed to install ${DISPLAY_NAME} from ${url} (${failureReason}). Retrying in ${INSTALL_RETRY_DELAY_MS}ms... (attempt ${attempt + 1}/${maxAttempts})`,
);
await sleep(INSTALL_RETRY_DELAY_MS);
}
}
}

throw new Error(
`Failed to install ${DISPLAY_NAME} after ${INSTALL_MAX_ATTEMPTS} attempts: ${failureReason}`,
`Failed to install ${DISPLAY_NAME} after ${maxAttempts} attempts across ${urls.length} URL(s): ${failureReason}`,
);
}

async function runInstallCommand(env: { [key: string]: string }): Promise<number> {
async function runInstallCommand(url: string, env: { [key: string]: string }): Promise<number> {
const options = { env, ignoreReturnCode: true };
if (process.platform === "win32") {
return exec(
"pwsh",
["-Command", `& ([scriptblock]::Create((irm ${INSTALL_URL_PS1})))`],
["-Command", `& ([scriptblock]::Create((irm -TimeoutSec ${PWSH_TIMEOUT_SEC} ${url})))`],
options,
);
}
return exec("bash", ["-c", `curl -fsSL ${INSTALL_URL_SH} | bash`], options);
return exec(
"bash",
["-c", `set -o pipefail; curl -fsSL ${CURL_TIMEOUT_FLAGS} ${url} | bash`],
options,
);
}

function ensureVitePlusBinInPath(): void {
Expand Down
Loading