From 0c86c3f8921e3d9b5817382afc3a92ab8cc77cb5 Mon Sep 17 00:00:00 2001 From: li-xiu-qi Date: Mon, 19 Oct 2026 22:19:40 +0800 Subject: [PATCH] fix(step): resolve update archive root for flat Windows zips Windows release zips place step.exe and the resources at the archive root, while Unix tar.gz archives wrap everything in a single top-level directory. findArchiveRoot assumed the wrapped layout and descended into the first subdirectory it saw, so on Windows the search for step.exe was confined to docs/ and step update always failed after a successful download and checksum verification. Anchor the decision on where the binary sits instead: if the binary is at the extract root, the root is the archive root (flat Windows zip); otherwise a single subdirectory is the wrapper (Unix archive); ambiguous layouts fall back to the extract root for the recursive findFile. This matches how infra/release/install.sh already derives the root and leaves install.ps1's flat assumption untouched. Add unit tests covering both archive layouts plus the fallback cases. --- .../src/step/local-update.test.ts | 48 +++++++++++++++++++ .../coding-agent/src/step/local-update.ts | 17 +++++-- 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/src/step/local-update.test.ts diff --git a/packages/coding-agent/src/step/local-update.test.ts b/packages/coding-agent/src/step/local-update.test.ts new file mode 100644 index 000000000..2290838aa --- /dev/null +++ b/packages/coding-agent/src/step/local-update.test.ts @@ -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); +}); diff --git a/packages/coding-agent/src/step/local-update.ts b/packages/coding-agent/src/step/local-update.ts index ac85d65f3..da37f7b64 100644 --- a/packages/coding-agent/src/step/local-update.ts +++ b/packages/coding-agent/src/step/local-update.ts @@ -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); @@ -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 { +/** + * 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 { 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 {