From 2b95676ff74113efa912278dffd5085729a7788a Mon Sep 17 00:00:00 2001 From: aifanatic Date: Wed, 4 Nov 2026 16:18:48 -0600 Subject: [PATCH 1/3] fix(cli): validate downloaded updates before replacing the installation --- .../src/commands/update.integration.test.ts | 88 +++++++++++++++++++ apps/cli/src/commands/update.test.ts | 45 +++++++--- apps/cli/src/commands/update.ts | 20 +++-- docs/architecture/cli-distribution.md | 7 ++ 4 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/commands/update.integration.test.ts diff --git a/apps/cli/src/commands/update.integration.test.ts b/apps/cli/src/commands/update.integration.test.ts new file mode 100644 index 000000000..c0844995f --- /dev/null +++ b/apps/cli/src/commands/update.integration.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { chmodSync, copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const bundlePath = resolve(import.meta.dirname, "../../dist/scope.mjs"); +let directory: string | undefined; +let server: Server | undefined; + +afterEach(async () => { + if (server) await new Promise((resolveClose) => server!.close(() => resolveClose())); + if (directory) rmSync(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; +}); + +/** Exercise the real installed bundle with a local release list and fake downloader. */ +async function runUpdate(downloadedSource: string) { + directory = mkdtempSync(join(tmpdir(), "scope-update-")); + const installedPath = join(directory, "scope"); + copyFileSync(bundlePath, installedPath); + const before = readFileSync(installedPath, "utf-8"); + + // The fake gh only writes the supplied synthetic bundle to the requested path. + const downloaderPath = join(directory, "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); + + server = createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ tag_name: "cli/v9.0.0" }])); + }); + await new Promise((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing mock server port"); + + let failed = false; + try { + await execFileAsync(process.execPath, ["--no-global-search-paths", installedPath, "update"], { + cwd: directory, + env: { + ...process.env, + NODE_PATH: "", + NODE_OPTIONS: "", + PATH: `${directory}:${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(directory) }; +} + +describe.skipIf(process.platform === "win32")("standalone CLI update validation", () => { + it.each([ + ["invalid JavaScript", "this is not JavaScript"], + ["wrong version", 'console.log("8.0.0");'], + ])("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 = 'console.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"]); + }); +}); diff --git a/apps/cli/src/commands/update.test.ts b/apps/cli/src/commands/update.test.ts index 25aec639a..362269fe4 100644 --- a/apps/cli/src/commands/update.test.ts +++ b/apps/cli/src/commands/update.test.ts @@ -6,6 +6,7 @@ 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 @@ -22,12 +23,13 @@ vi.mock("../utils/update-check.js", () => ({ })); import { Command } from "commander"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { chmodSync, 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 mockedRenameSync = vi.mocked(renameSync); @@ -40,6 +42,7 @@ describe("update command", () => { let errorSpy: ReturnType; beforeEach(() => { + vi.clearAllMocks(); program = new Command(); program.exitOverride(); // throw instead of process.exit registerUpdateCommand(program); @@ -50,12 +53,9 @@ 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"); + // 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 +106,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 +180,41 @@ 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).toHaveBeenCalledWith( + process.execPath, + [expect.stringMatching(/\.tmp\.mjs$/), "--version"], + expect.objectContaining({ + encoding: "utf-8", + timeout: 10_000, + env: expect.objectContaining({ SCOPE_NO_UPDATE_CHECK: "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("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..58fb91a6a 100644 --- a/apps/cli/src/commands/update.ts +++ b/apps/cli/src/commands/update.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Command } from "commander"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { chmodSync, renameSync, unlinkSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import semver from "semver"; @@ -64,19 +64,27 @@ 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" }, ); + // 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, + 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..e9042ef5e 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -168,6 +168,13 @@ 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, +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 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: From 8a001dac340e8983fe91066da87c93c9671115e1 Mon Sep 17 00:00:00 2001 From: aifanatic Date: Wed, 4 Nov 2026 16:23:35 -0600 Subject: [PATCH 2/3] test(cli): include updater regressions in the published bundle gate --- apps/cli/src/bundle.integration.test.ts | 83 ++++++++++++++++- .../src/commands/update.integration.test.ts | 88 ------------------- 2 files changed, 80 insertions(+), 91 deletions(-) delete mode 100644 apps/cli/src/commands/update.integration.test.ts diff --git a/apps/cli/src/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index 11d8e0714..408ba10b8 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,80 @@ 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", "this is not JavaScript"], + ["wrong version", 'console.log("8.0.0");'], + ])("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 = 'console.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"]); + }); +}); diff --git a/apps/cli/src/commands/update.integration.test.ts b/apps/cli/src/commands/update.integration.test.ts deleted file mode 100644 index c0844995f..000000000 --- a/apps/cli/src/commands/update.integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { afterEach, describe, expect, it } from "vitest"; -import { execFile } from "node:child_process"; -import { createServer, type Server } from "node:http"; -import { chmodSync, copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); -const bundlePath = resolve(import.meta.dirname, "../../dist/scope.mjs"); -let directory: string | undefined; -let server: Server | undefined; - -afterEach(async () => { - if (server) await new Promise((resolveClose) => server!.close(() => resolveClose())); - if (directory) rmSync(directory, { recursive: true, force: true }); - server = undefined; - directory = undefined; -}); - -/** Exercise the real installed bundle with a local release list and fake downloader. */ -async function runUpdate(downloadedSource: string) { - directory = mkdtempSync(join(tmpdir(), "scope-update-")); - const installedPath = join(directory, "scope"); - copyFileSync(bundlePath, installedPath); - const before = readFileSync(installedPath, "utf-8"); - - // The fake gh only writes the supplied synthetic bundle to the requested path. - const downloaderPath = join(directory, "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); - - server = createServer((_request, response) => { - response.setHeader("content-type", "application/json"); - response.end(JSON.stringify([{ tag_name: "cli/v9.0.0" }])); - }); - await new Promise((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("Missing mock server port"); - - let failed = false; - try { - await execFileAsync(process.execPath, ["--no-global-search-paths", installedPath, "update"], { - cwd: directory, - env: { - ...process.env, - NODE_PATH: "", - NODE_OPTIONS: "", - PATH: `${directory}:${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(directory) }; -} - -describe.skipIf(process.platform === "win32")("standalone CLI update validation", () => { - it.each([ - ["invalid JavaScript", "this is not JavaScript"], - ["wrong version", 'console.log("8.0.0");'], - ])("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 = 'console.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"]); - }); -}); From 46faecb93b8c6a52050efebb511497b9b8b9c411 Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Fri, 13 Nov 2026 15:33:25 -0600 Subject: [PATCH 3/3] fix(cli): reject non-executable updates and force validation timeout Signed-off-by: Naveen Chatlapalli --- apps/cli/src/bundle.integration.test.ts | 15 ++++++++--- apps/cli/src/commands/update.test.ts | 35 ++++++++++++++++++------- apps/cli/src/commands/update.ts | 6 ++++- docs/architecture/cli-distribution.md | 11 ++++---- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/bundle.integration.test.ts b/apps/cli/src/bundle.integration.test.ts index 408ba10b8..a56dc9db7 100644 --- a/apps/cli/src/bundle.integration.test.ts +++ b/apps/cli/src/bundle.integration.test.ts @@ -236,8 +236,13 @@ describe.skipIf(process.platform === "win32")("standalone CLI update validation" } it.each([ - ["invalid JavaScript", "this is not JavaScript"], - ["wrong version", 'console.log("8.0.0");'], + ["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); @@ -246,10 +251,14 @@ describe.skipIf(process.platform === "win32")("standalone CLI update validation" }); it("installs a matching executable bundle", async () => { - const downloadedSource = 'console.log("9.0.0");'; + 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 362269fe4..6657f579e 100644 --- a/apps/cli/src/commands/update.test.ts +++ b/apps/cli/src/commands/update.test.ts @@ -12,6 +12,7 @@ vi.mock("node:child_process", () => ({ // Mock fs operations used during update vi.mock("node:fs", () => ({ chmodSync: vi.fn(), + readFileSync: vi.fn(), renameSync: vi.fn(), unlinkSync: vi.fn(), })); @@ -24,7 +25,7 @@ vi.mock("../utils/update-check.js", () => ({ import { Command } from "commander"; import { execFileSync, execSync } from "node:child_process"; -import { chmodSync, renameSync, unlinkSync } from "node:fs"; +import { chmodSync, readFileSync, renameSync, unlinkSync } from "node:fs"; import { fetchLatestVersion } from "../utils/update-check.js"; import { registerUpdateCommand } from "./update.js"; @@ -32,6 +33,7 @@ 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); @@ -54,6 +56,7 @@ describe("update command", () => { // Simulate bundled CLI process.env.SCOPE_CLI_VERSION = "0.2.0"; 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("gh release list")) { @@ -185,15 +188,14 @@ describe("update command", () => { await program.parseAsync(["node", "scope", "update"]); - expect(mockedExecFileSync).toHaveBeenCalledWith( - process.execPath, - [expect.stringMatching(/\.tmp\.mjs$/), "--version"], - expect.objectContaining({ - encoding: "utf-8", - timeout: 10_000, - env: expect.objectContaining({ SCOPE_NO_UPDATE_CHECK: "1" }), - }), - ); + 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"), @@ -209,6 +211,19 @@ describe("update command", () => { 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"); }); diff --git a/apps/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts index 58fb91a6a..8d8eb7515 100644 --- a/apps/cli/src/commands/update.ts +++ b/apps/cli/src/commands/update.ts @@ -3,7 +3,7 @@ import { Command } from "commander"; import { execFileSync, execSync } from "node:child_process"; -import { chmodSync, renameSync, unlinkSync } from "node:fs"; +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"; @@ -72,11 +72,15 @@ export function registerUpdateCommand(program: Command): void { `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)) { diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index e9042ef5e..687aaf34e 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -169,11 +169,12 @@ 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, -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 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. +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