Skip to content
Open
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
92 changes: 89 additions & 3 deletions apps/cli/src/bundle.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<void>((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<void>((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");
});
});
64 changes: 52 additions & 12 deletions apps/cli/src/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand All @@ -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);

Expand All @@ -40,6 +44,7 @@ describe("update command", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
program = new Command();
program.exitOverride(); // throw instead of process.exit
registerUpdateCommand(program);
Expand All @@ -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";
}
Expand Down Expand Up @@ -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"]);

Expand Down Expand Up @@ -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();
});
});
26 changes: 19 additions & 7 deletions apps/cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"], {
Comment thread
1aifanatic marked this conversation as resolved.
Resolved
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
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/cli-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down