Skip to content

Commit a0d014a

Browse files
committed
fix: preserve portable installer shell compatibility
1 parent e5b7b68 commit a0d014a

8 files changed

Lines changed: 223 additions & 18 deletions

File tree

dist/azure/index.mjs

Lines changed: 6 additions & 4 deletions
Large diffs are not rendered by default.

dist/gitlab/index.mjs

Lines changed: 6 additions & 4 deletions
Large diffs are not rendered by default.

dist/index.mjs

Lines changed: 5 additions & 3 deletions
Large diffs are not rendered by default.

src/ci/install-viteplus.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process";
3+
import { writeFileSync } from "node:fs";
4+
import { installVitePlus } from "./install-viteplus.js";
5+
6+
const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() }));
7+
vi.mock("node:child_process", () => ({ spawnSync }));
8+
9+
function result(status: number | null, error?: NodeJS.ErrnoException): SpawnSyncReturns<Buffer> {
10+
return {
11+
pid: 1,
12+
output: [],
13+
stdout: Buffer.alloc(0),
14+
stderr: Buffer.alloc(0),
15+
status,
16+
signal: null,
17+
error,
18+
};
19+
}
20+
21+
const missingCommand = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
22+
const options = {
23+
platform: "win32" as const,
24+
sleep: async () => undefined,
25+
logWarningFn: () => undefined,
26+
};
27+
28+
beforeEach(() => {
29+
spawnSync.mockReset();
30+
});
31+
32+
describe("portable installer shell selection", () => {
33+
it.each(["0.2.9", "0.3.1"])(
34+
"uses Windows PowerShell when pwsh is missing for Vite+ %s",
35+
async (version) => {
36+
spawnSync.mockImplementation(
37+
(command: string, _args: string[], spawnOptions: SpawnSyncOptions) => {
38+
if (command === "pwsh") return result(null, missingCommand);
39+
if (command !== "powershell.exe") throw new Error(`Unexpected shell: ${command}`);
40+
const dirsFile = spawnOptions.env?.SETUP_VP_DIRS_FILE;
41+
if (dirsFile) {
42+
writeFileSync(
43+
dirsFile,
44+
"data\t/data\nbin\t/bin\ncache\t/cache\nconfig\t/config\nstate\t/state\n",
45+
);
46+
}
47+
return result(0);
48+
},
49+
);
50+
await installVitePlus(version, { ...options, env: { PATH: "" } });
51+
52+
expect(spawnSync.mock.calls.map(([command]) => command)).toEqual(["pwsh", "powershell.exe"]);
53+
expect(spawnSync.mock.calls[1]!.slice(1)).toEqual(spawnSync.mock.calls[0]!.slice(1));
54+
},
55+
);
56+
57+
it("prefers pwsh when it is available", async () => {
58+
spawnSync.mockReturnValue(result(0));
59+
await installVitePlus("0.2.9", { ...options, env: { PATH: "" } });
60+
expect(spawnSync).toHaveBeenCalledTimes(1);
61+
expect(spawnSync.mock.calls[0]![0]).toBe("pwsh");
62+
});
63+
64+
it.each([
65+
{ status: 23, error: undefined, reason: "exit code 23" },
66+
{
67+
status: null,
68+
error: Object.assign(new Error("spawn EACCES"), { code: "EACCES" }),
69+
reason: "EACCES",
70+
},
71+
])("does not switch shells after $reason", async ({ status, error, reason }) => {
72+
spawnSync.mockReturnValue(result(status, error));
73+
await expect(installVitePlus("0.2.9", { ...options, env: { PATH: "" } })).rejects.toThrow(
74+
reason,
75+
);
76+
expect(spawnSync).toHaveBeenCalledTimes(8);
77+
expect(spawnSync.mock.calls.every(([command]) => command === "pwsh")).toBe(true);
78+
});
79+
80+
it("does not try PowerShell when bash is missing on Unix", async () => {
81+
spawnSync.mockReturnValue(result(null, missingCommand));
82+
await expect(
83+
installVitePlus("0.2.9", { ...options, platform: "linux", env: { PATH: "" } }),
84+
).rejects.toThrow("ENOENT");
85+
expect(spawnSync).toHaveBeenCalledTimes(8);
86+
expect(spawnSync.mock.calls.every(([command]) => command === "bash")).toBe(true);
87+
});
88+
});

src/ci/install-viteplus.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,19 @@ function runInstallCommand(
2929
platform: NodeJS.Platform = process.platform,
3030
): number {
3131
const { command, args } = getInstallScriptCommand(url, platform, env.VP_VPDIRS_AWARE === "1");
32-
const result = spawnSync(command, args, {
32+
const spawnOptions = {
3333
env: { ...process.env, ...env },
34-
stdio: "inherit",
35-
});
34+
stdio: "inherit" as const,
35+
};
36+
let result = spawnSync(command, args, spawnOptions);
37+
// GitLab PowerShell Desktop runners need not have PowerShell Core installed.
38+
// Only fall back when the executable is missing, not when an installer fails.
39+
if (
40+
platform === "win32" &&
41+
(result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
42+
) {
43+
result = spawnSync("powershell.exe", args, spawnOptions);
44+
}
3645
if (result.error) throw result.error;
3746
return result.status ?? 1;
3847
}

src/ci/vp-dirs.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
import { describe, expect, it } from "vite-plus/test";
2+
import { spawnSync } from "node:child_process";
3+
import {
4+
chmodSync,
5+
existsSync,
6+
mkdirSync,
7+
mkdtempSync,
8+
readFileSync,
9+
rmSync,
10+
writeFileSync,
11+
} from "node:fs";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
214
import { getInstallScriptCommand, parseVitePlusDirs, supportsVitePlusDirs } from "./vp-dirs.js";
315

416
describe("Vite+ directory resolution", () => {
@@ -53,6 +65,90 @@ describe("Vite+ directory resolution", () => {
5365
expect(command.args[1]).toContain('>> "$SETUP_VP_DIRS_FILE"');
5466
});
5567

68+
it.skipIf(process.platform === "win32").each([true, false])(
69+
"isolates inherited nounset and preserves installer failures (detectDirs: %s)",
70+
(detectDirs) => {
71+
const fixture = mkdtempSync(join(tmpdir(), "setup-vp-shell-options-"));
72+
const bin = join(fixture, "bin");
73+
const installer = join(fixture, "installer.sh");
74+
const dirsFile = join(fixture, "dirs");
75+
const continued = join(fixture, "continued");
76+
mkdirSync(bin);
77+
writeFileSync(
78+
join(bin, "curl"),
79+
`#!/usr/bin/env bash
80+
if [ "$#" -eq 8 ]; then
81+
cp "$SETUP_VP_TEST_INSTALLER" "$8"
82+
else
83+
cat "$SETUP_VP_TEST_INSTALLER"
84+
fi
85+
`,
86+
);
87+
writeFileSync(
88+
join(bin, "vp"),
89+
`#!/usr/bin/env bash
90+
if [ "\${VP_DUMP_DIRS:-}" = "1" ]; then
91+
printf 'data\\t/data\\nbin\\t/bin\\ncache\\t/cache\\nconfig\\t/config\\nstate\\t/state\\n'
92+
else
93+
printf 'vp v0.3.0\\n'
94+
fi
95+
`,
96+
);
97+
chmodSync(join(bin, "curl"), 0o755);
98+
chmodSync(join(bin, "vp"), 0o755);
99+
const command = getInstallScriptCommand(
100+
"https://example.com/install.sh",
101+
"linux",
102+
detectDirs,
103+
);
104+
const runInstaller = () =>
105+
spawnSync(command.command, command.args, {
106+
encoding: "utf8",
107+
env: {
108+
...process.env,
109+
PATH: `${bin}:${process.env.PATH}`,
110+
SHELLOPTS: "nounset",
111+
SETUP_VP_DIRS_FILE: dirsFile,
112+
SETUP_VP_TEST_INSTALLER: installer,
113+
SETUP_VP_TEST_SHIM_DIR: bin,
114+
SETUP_VP_TEST_CONTINUED: continued,
115+
},
116+
});
117+
118+
try {
119+
writeFileSync(
120+
installer,
121+
`set -e
122+
setup_vp_test_optional() { local optional="$4"; }
123+
setup_vp_test_optional one two three
124+
SHIM_DIR="$SETUP_VP_TEST_SHIM_DIR"
125+
printf 'installer completed\\n'
126+
`,
127+
);
128+
const success = runInstaller();
129+
expect(success.status, success.stderr).toBe(0);
130+
expect(success.stderr).toBe("");
131+
expect(success.stdout).toContain("installer completed");
132+
if (detectDirs) {
133+
expect(parseVitePlusDirs(readFileSync(dirsFile, "utf8"))?.bin).toBe("/bin");
134+
}
135+
136+
writeFileSync(installer, "exit 23\n");
137+
expect(runInstaller().status).toBe(23);
138+
if (detectDirs) {
139+
writeFileSync(installer, "return 23\n");
140+
expect(runInstaller().status).toBe(23);
141+
}
142+
143+
writeFileSync(installer, 'set -e\nfalse\nprintf continued > "$SETUP_VP_TEST_CONTINUED"\n');
144+
expect(runInstaller().status).not.toBe(0);
145+
expect(existsSync(continued)).toBe(false);
146+
} finally {
147+
rmSync(fixture, { recursive: true, force: true });
148+
}
149+
},
150+
);
151+
56152
it("dumps directories from the installer-resolved Windows shim", () => {
57153
const command = getInstallScriptCommand("https://example.com/install.ps1", "win32");
58154

@@ -61,6 +157,8 @@ describe("Vite+ directory resolution", () => {
61157
expect(command.args[1]).toContain("Join-Path $vpDir 'vp.exe'");
62158
expect(command.args[1]).toContain("& $vpPath --version");
63159
expect(command.args[1]).toContain("$env:VP_DUMP_DIRS = '1'");
160+
expect(command.args[1]).toContain("Set-Content -LiteralPath $dirsFile -Encoding UTF8");
161+
expect(command.args[1]).toContain("Add-Content -LiteralPath $dirsFile -Encoding UTF8");
64162
});
65163

66164
it.each([

src/ci/vp-dirs.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function getInstallScriptCommand(
121121

122122
const script = `
123123
$dirsFile = $env:${VP_DIRS_FILE_ENV}
124-
Set-Content -LiteralPath $dirsFile -Value '' -NoNewline
124+
Set-Content -LiteralPath $dirsFile -Value '' -NoNewline -Encoding UTF8
125125
. ([scriptblock]::Create((irm -TimeoutSec ${PWSH_TIMEOUT_SEC} ${url})))
126126
$vpDir = if ($script:ShimDir) {
127127
$script:ShimDir
@@ -135,25 +135,29 @@ if (-not (Test-Path -LiteralPath $vpPath)) {
135135
$vpPath = Join-Path $vpDir 'vp.cmd'
136136
}
137137
if (Test-Path -LiteralPath $vpPath) {
138-
& $vpPath --version | Set-Content -LiteralPath $dirsFile
138+
& $vpPath --version | Set-Content -LiteralPath $dirsFile -Encoding UTF8
139139
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
140140
$env:VP_DUMP_DIRS = '1'
141-
& $vpPath | Add-Content -LiteralPath $dirsFile
141+
& $vpPath | Add-Content -LiteralPath $dirsFile -Encoding UTF8
142142
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
143143
}
144144
`.trim();
145145
return { command: "pwsh", args: ["-Command", script] };
146146
}
147147

148+
// Upstream installers read optional positional arguments without defaults.
149+
// Do not inherit nounset from a caller that exports SHELLOPTS.
148150
if (!detectDirs) {
149151
const script = `
152+
set +u
150153
set -o pipefail
151154
curl -fsSL ${CURL_TIMEOUT_FLAGS} ${url} | bash
152155
`.trim();
153156
return { command: "bash", args: ["-c", script] };
154157
}
155158

156159
const script = `
160+
set +u
157161
set -eo pipefail
158162
installer_file="$(mktemp "\${TMPDIR:-/tmp}/setup-vp-install.XXXXXX")"
159163
trap 'rm -f "$installer_file"' EXIT

src/install-viteplus.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ describe("installVitePlus", () => {
270270
const [cmd, args] = vi.mocked(exec).mock.calls[0];
271271
expect(cmd).toBe("bash");
272272
const script = (args as string[])[1];
273-
expect(script).toMatch(/^set -eo pipefail\n/);
273+
expect(script).toMatch(/^set \+u\nset -eo pipefail\n/);
274274
expect(script).toContain("--connect-timeout");
275275
expect(script).toContain("--max-time");
276276
expect(script).toContain('-o "$installer_file"');

0 commit comments

Comments
 (0)