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
48 changes: 48 additions & 0 deletions packages/coding-agent/src/step/local-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { findArchiveRoot } from "./local-update.ts";

let extractDir: string;

beforeEach(async () => {
extractDir = await mkdtemp(path.join(os.tmpdir(), "step-update-archive-root-"));
});

afterEach(async () => {
await rm(extractDir, { recursive: true, force: true });
});

test("a flat Windows zip keeps the extract dir as archive root", async () => {
await writeFile(path.join(extractDir, "step.exe"), "binary");
await mkdir(path.join(extractDir, "docs"));
await mkdir(path.join(extractDir, "node_modules"));

expect(await findArchiveRoot(extractDir, "step.exe")).toBe(extractDir);
});

test("a wrapped Unix archive descends into the single top-level directory", async () => {
const wrapper = path.join(extractDir, "step");
await mkdir(wrapper);
await writeFile(path.join(wrapper, "step"), "binary");
await mkdir(path.join(wrapper, "docs"));

expect(await findArchiveRoot(extractDir, "step")).toBe(wrapper);
});

test("a wrapper directory wins over loose files at the extract root", async () => {
const wrapper = path.join(extractDir, "step");
await mkdir(wrapper);
await writeFile(path.join(wrapper, "step"), "binary");
await writeFile(path.join(extractDir, "README.md"), "notes");

expect(await findArchiveRoot(extractDir, "step")).toBe(wrapper);
});

test("ambiguous layouts fall back to the extract dir for recursive search", async () => {
await mkdir(path.join(extractDir, "bin"));
await mkdir(path.join(extractDir, "lib"));

expect(await findArchiveRoot(extractDir, "step")).toBe(extractDir);
});
17 changes: 13 additions & 4 deletions packages/coding-agent/src/step/local-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ export async function runStepUpdateCommand(input: StepUpdateCommandInput = {}):
await mkdir(extractDir);
const extractCode = await extractArchive(archivePath, extractDir);
if (extractCode !== 0) throw new Error("could not extract the release archive");
const archiveRoot = await findArchiveRoot(extractDir);
const binaryName = process.platform === "win32" ? "step.exe" : "step";
const archiveRoot = await findArchiveRoot(extractDir, binaryName);
const stagedBinary = await findFile(archiveRoot, binaryName);
if (!stagedBinary) throw new Error("release archive does not contain the Step binary");
const smoke = await verifyBinary(stagedBinary, targetVersion);
Expand Down Expand Up @@ -536,10 +536,19 @@ async function extractArchive(archivePath: string, extractDir: string): Promise<
return waitForChildProcess(spawnProcess("tar", ["-xzf", archivePath, "-C", extractDir], { stdio: "inherit" }));
}

async function findArchiveRoot(extractDir: string): Promise<string> {
/**
* Resolve the directory that holds the Step binary and the resources listed in
* STEP_UPDATE_TARGETS. Release archives come in two shapes: Unix archives wrap
* everything in a single top-level directory (e.g. `step/`), while Windows
* zips place the files at the archive root. Decide by where the binary sits,
* not by which subdirectory happens to sort first.
*/
export async function findArchiveRoot(extractDir: string, binaryName: string): Promise<string> {
const entries = await readdir(extractDir, { withFileTypes: true });
const directory = entries.find((entry) => entry.isDirectory());
return directory ? path.join(extractDir, directory.name) : extractDir;
if (entries.some((entry) => entry.isFile() && entry.name === binaryName)) return extractDir;
const directories = entries.filter((entry) => entry.isDirectory());
if (directories.length === 1) return path.join(extractDir, directories[0].name);
return extractDir;
}

async function findFile(root: string, fileName: string): Promise<string | undefined> {
Expand Down
Loading