diff --git a/apps/cli/src/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index 11d8e0714..a56dc9db7 100644 --- a/apps/cli/src/bundle.integration.test.ts +++ b/apps/cli/src/bundle.integration.test.ts @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; import { execFile } from "node:child_process"; import { createServer, type Server } from "node:http"; import { promisify } from "node:util"; -import { resolve, join } from "node:path"; -import { existsSync, copyFileSync, chmodSync, mkdtempSync, rmSync } from "node:fs"; +import { resolve, join, dirname } from "node:path"; +import { existsSync, copyFileSync, chmodSync, mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; const execFileAsync = promisify(execFile); @@ -176,3 +176,89 @@ describe("Bundle integration tests", () => { } }); }); + +// Keep updater cases in the bundle suite invoked by CI and CLI publication. +describe.skipIf(process.platform === "win32")("standalone CLI update validation", () => { + let updateDirectory: string | undefined; + let releaseServer: Server | undefined; + + afterEach(async () => { + if (releaseServer) await new Promise((resolveClose) => releaseServer!.close(() => resolveClose())); + if (updateDirectory) rmSync(updateDirectory, { recursive: true, force: true }); + releaseServer = undefined; + updateDirectory = undefined; + }); + + /** Exercise the real installed bundle with a local release list and fake downloader. */ + async function runUpdate(downloadedSource: string) { + updateDirectory = mkdtempSync(join(tmpdir(), "scope-update-")); + const installedPath = join(updateDirectory, "scope"); + copyFileSync(BUNDLE_PATH, installedPath); + const before = readFileSync(installedPath, "utf-8"); + + // The fake gh only writes the supplied synthetic bundle to the requested path. + const downloaderPath = join(updateDirectory, "gh"); + writeFileSync(downloaderPath, `#!/usr/bin/env node + const fs = require("node:fs"); + const args = process.argv.slice(2); + if (args[0] !== "release" || args[1] !== "download") process.exit(2); + fs.writeFileSync(args[args.indexOf("-O") + 1], process.env.SCOPE_TEST_BUNDLE); + `); + chmodSync(downloaderPath, 0o755); + + releaseServer = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ tag_name: "cli/v9.0.0" }])); + }); + await new Promise((resolveListen) => releaseServer!.listen(0, "127.0.0.1", resolveListen)); + const address = releaseServer.address(); + if (!address || typeof address === "string") throw new Error("Missing mock release server port"); + + let failed = false; + try { + await execFileAsync(process.execPath, ["--no-global-search-paths", installedPath, "update"], { + cwd: updateDirectory, + env: { + ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", + PATH: `${updateDirectory}:${dirname(process.execPath)}:${process.env.PATH ?? ""}`, + SCOPE_NO_UPDATE_CHECK: "1", + SCOPE_RELEASES_URL: `http://127.0.0.1:${address.port}`, + SCOPE_TEST_BUNDLE: downloadedSource, + }, + timeout: 15_000, + }); + } catch { + failed = true; + } + return { failed, before, after: readFileSync(installedPath, "utf-8"), files: readdirSync(updateDirectory) }; + } + + it.each([ + ["invalid JavaScript", "#!/usr/bin/env node\nthis is not JavaScript"], + ["missing executable header", 'console.log("9.0.0");'], + ["wrong version", '#!/usr/bin/env node\nconsole.log("8.0.0");'], + [ + "a bundle that ignores graceful termination", + '#!/usr/bin/env node\nprocess.on("SIGTERM", () => {}); console.log("9.0.0"); setInterval(() => {}, 1000);', + ], + ])("preserves the installed CLI for %s", async (_description, downloadedSource) => { + const result = await runUpdate(downloadedSource); + expect(result.failed).toBe(true); + expect(result.after).toBe(result.before); + expect(result.files).toEqual(["gh", "scope"]); + }); + + it("installs a matching executable bundle", async () => { + const downloadedSource = '#!/usr/bin/env node\nconsole.log("9.0.0");'; + const result = await runUpdate(downloadedSource); + expect(result.failed).toBe(false); + expect(result.after).toBe(downloadedSource); + expect(result.files).toEqual(["gh", "scope"]); + const { stdout } = await execFileAsync(join(updateDirectory!, "scope"), ["--version"], { + timeout: 10_000, + }); + expect(stdout.trim()).toBe("9.0.0"); + }); +}); diff --git a/apps/cli/src/commands/update.test.ts b/apps/cli/src/commands/update.test.ts index 25aec639a..6657f579e 100644 --- a/apps/cli/src/commands/update.test.ts +++ b/apps/cli/src/commands/update.test.ts @@ -6,11 +6,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock child_process before importing the module under test vi.mock("node:child_process", () => ({ execSync: vi.fn(), + execFileSync: vi.fn(), })); // Mock fs operations used during update vi.mock("node:fs", () => ({ chmodSync: vi.fn(), + readFileSync: vi.fn(), renameSync: vi.fn(), unlinkSync: vi.fn(), })); @@ -22,14 +24,16 @@ vi.mock("../utils/update-check.js", () => ({ })); import { Command } from "commander"; -import { execSync } from "node:child_process"; -import { chmodSync, renameSync, unlinkSync } from "node:fs"; +import { execFileSync, execSync } from "node:child_process"; +import { chmodSync, readFileSync, renameSync, unlinkSync } from "node:fs"; import { fetchLatestVersion } from "../utils/update-check.js"; import { registerUpdateCommand } from "./update.js"; const mockedExecSync = vi.mocked(execSync); +const mockedExecFileSync = vi.mocked(execFileSync); const mockedFetchLatestVersion = vi.mocked(fetchLatestVersion); const mockedChmodSync = vi.mocked(chmodSync); +const mockedReadFileSync = vi.mocked(readFileSync); const mockedRenameSync = vi.mocked(renameSync); const mockedUnlinkSync = vi.mocked(unlinkSync); @@ -40,6 +44,7 @@ describe("update command", () => { let errorSpy: ReturnType; beforeEach(() => { + vi.clearAllMocks(); program = new Command(); program.exitOverride(); // throw instead of process.exit registerUpdateCommand(program); @@ -50,12 +55,10 @@ describe("update command", () => { errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // Simulate bundled CLI process.env.SCOPE_CLI_VERSION = "0.2.0"; - // Mock execSync: gh release download returns void (stdio: inherit), - // version check returns version string, gh release list returns tag + mockedExecFileSync.mockReturnValue("0.3.0\n"); + mockedReadFileSync.mockReturnValue("#!/usr/bin/env node\n"); + // The download has no captured output; the fallback lookup returns its tag. mockedExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("--version")) { - return "0.3.0\n"; - } if (typeof cmd === "string" && cmd.includes("gh release list")) { return "cli/v0.3.0\n"; } @@ -106,6 +109,7 @@ describe("update command", () => { it("proceeds with update when --force is passed even if up to date", async () => { mockedFetchLatestVersion.mockResolvedValue("0.2.0"); + mockedExecFileSync.mockReturnValue("0.2.0\n"); await program.parseAsync(["node", "scope", "update", "--force"]); @@ -179,17 +183,53 @@ describe("update command", () => { ); }); - it("verifies new version after install", async () => { + it("verifies the staged bundle before replacing the installation", async () => { mockedFetchLatestVersion.mockResolvedValue("0.3.0"); await program.parseAsync(["node", "scope", "update"]); - expect(mockedExecSync).toHaveBeenCalledWith( - expect.stringContaining("--version"), - expect.objectContaining({ encoding: "utf-8" }), - ); + expect(mockedExecFileSync).toHaveBeenCalledOnce(); + const [executable, args, options] = mockedExecFileSync.mock.calls[0]; + expect(executable).toBe(process.execPath); + expect(args).toEqual([expect.stringMatching(/\.tmp\.mjs$/), "--version"]); + expect(options?.encoding).toBe("utf-8"); + expect(options?.timeout).toBe(10_000); + expect(options?.killSignal).toBe("SIGKILL"); + expect(options?.env?.SCOPE_NO_UPDATE_CHECK).toBe("1"); + expect(mockedExecFileSync.mock.invocationCallOrder[0]).toBeLessThan(mockedRenameSync.mock.invocationCallOrder[0]); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("Updated scope to v0.3.0"), ); }); + + it("keeps the current installation when the downloaded bundle reports the wrong version", async () => { + mockedFetchLatestVersion.mockResolvedValue("0.3.0"); + mockedExecFileSync.mockReturnValue("0.2.1\n"); + + await expect(program.parseAsync(["node", "scope", "update"])).rejects.toThrow("process.exit"); + expect(mockedRenameSync).not.toHaveBeenCalled(); + expect(mockedUnlinkSync).toHaveBeenCalled(); + }); + + it.each(["", "console.log('0.3.0');", "#!/bin/sh\n", "#!/usr/bin/env node\r\n"])( + "keeps the current installation for an invalid executable header: %j", + async (downloadedSource) => { + mockedFetchLatestVersion.mockResolvedValue("0.3.0"); + mockedReadFileSync.mockReturnValue(downloadedSource); + + await expect(program.parseAsync(["node", "scope", "update"])).rejects.toThrow("process.exit"); + expect(mockedExecFileSync).not.toHaveBeenCalled(); + expect(mockedRenameSync).not.toHaveBeenCalled(); + expect(mockedUnlinkSync).toHaveBeenCalled(); + }, + ); + + it("keeps the current installation when the staged bundle cannot run", async () => { + mockedFetchLatestVersion.mockResolvedValue("0.3.0"); + mockedExecFileSync.mockImplementation(() => { throw new Error("Invalid JavaScript"); }); + + await expect(program.parseAsync(["node", "scope", "update"])).rejects.toThrow("process.exit"); + expect(mockedRenameSync).not.toHaveBeenCalled(); + expect(mockedUnlinkSync).toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts index 0cbed9712..8d8eb7515 100644 --- a/apps/cli/src/commands/update.ts +++ b/apps/cli/src/commands/update.ts @@ -2,8 +2,8 @@ // Licensed under the MIT License. import { Command } from "commander"; -import { execSync } from "node:child_process"; -import { chmodSync, renameSync, unlinkSync } from "node:fs"; +import { execFileSync, execSync } from "node:child_process"; +import { chmodSync, readFileSync, renameSync, unlinkSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import semver from "semver"; import { getCliName } from "../utils/shared.js"; @@ -64,19 +64,31 @@ export function registerUpdateCommand(program: Command): void { const currentBin = resolve(process.argv[1]); const installDir = dirname(currentBin); const binaryName = basename(currentBin); - const tmpFile = join(installDir, `.${binaryName}.tmp`); + const tmpFile = join(installDir, `.${binaryName}.tmp.mjs`); try { - // Download scope.mjs to a temp file, then atomically replace + // Stage the download beside the installation for an atomic replacement. execSync( `gh release download "${targetTag}" --repo ${RELEASES_REPO} --pattern scope.mjs -O "${tmpFile}" --clobber`, { stdio: "inherit" }, ); + if (!readFileSync(tmpFile, "utf-8").startsWith("#!/usr/bin/env node\n")) { + throw new Error("Downloaded bundle is missing its executable Node header."); + } + // Validate the staged ESM bundle before touching the working CLI. Use + // this Node executable and argument passing so install paths are literal. + const newVersion = execFileSync(process.execPath, [tmpFile, "--version"], { + encoding: "utf-8", + timeout: 10_000, + killSignal: "SIGKILL", + env: { ...process.env, SCOPE_NO_UPDATE_CHECK: "1" }, + }).trim(); + if (newVersion !== targetTag.slice("cli/v".length)) { + throw new Error(`Downloaded bundle version does not match ${targetTag}.`); + } + chmodSync(tmpFile, 0o755); renameSync(tmpFile, currentBin); - - // Verify - const newVersion = execSync(`"${currentBin}" --version`, { encoding: "utf-8" }).trim(); console.log(`✓ Updated scope to v${newVersion} at ${currentBin}`); } catch { // Clean up temp file on failure diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index b2906b3ed..687aaf34e 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -168,6 +168,14 @@ maintaining another release lookup or installation implementation. in-place requires `gh` configured with GitHub authentication. Alternatively, rerun the public installer without `gh`. +The updater downloads into a temporary `.mjs` file beside the installed command, +requires the executable `#!/usr/bin/env node` header, runs it with the current Node +executable, and verifies its `--version` matches the selected release tag before +replacing the installation atomically. Validation has a ten-second timeout with +forced termination and disables update checks in the child process. A failed +download, invalid bundle, or version mismatch removes the temporary file and +leaves the installed CLI intact. + ## Update check After each command, the CLI performs a non-blocking check for newer versions: