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
134 changes: 134 additions & 0 deletions src/cli/commands/self-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import { Result as R } from "../../shared/result.ts";
import {
detectBinaryName,
type QuarantineRemover,
tryRemoveMacosQuarantine,
WINDOWS_UNSUPPORTED_MESSAGE,
} from "./self-update.ts";

describe("detectBinaryName", () => {
test("win32 → returns Windows-specific error pointing at install.ps1", () => {
const result = detectBinaryName("win32", "x64");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).toBe(WINDOWS_UNSUPPORTED_MESSAGE);
}
});

test("win32 message does not look like the generic unsupported-platform fallback", () => {
const result = detectBinaryName("win32", "x64");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).not.toMatch(/^Unsupported platform:/);
expect(result.error.message).toContain("install.ps1");
}
});

test("other unsupported platforms keep the generic message", () => {
// freebsd is not in the supported set and is not win32, so it must fall through
// to the generic "Unsupported platform: …" branch.
const result = detectBinaryName("freebsd" as NodeJS.Platform, "x64");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).toBe("Unsupported platform: freebsd/x64");
}
});

test("unsupported arch on a supported os keeps the generic message", () => {
const result = detectBinaryName("linux", "ia32");
expect(R.isErr(result)).toBe(true);
if (R.isErr(result)) {
expect(result.error.message).toBe("Unsupported platform: linux/ia32");
}
});

test("darwin/arm64 → wt-darwin-arm64", () => {
const result = detectBinaryName("darwin", "arm64");
expect(R.isOk(result)).toBe(true);
if (R.isOk(result)) {
expect(result.data).toBe("wt-darwin-arm64");
}
});

test("darwin/x64 → wt-darwin-x64", () => {
const result = detectBinaryName("darwin", "x64");
expect(R.isOk(result)).toBe(true);
if (R.isOk(result)) {
expect(result.data).toBe("wt-darwin-x64");
}
});

test("linux/x64 → wt-linux-x64", () => {
const result = detectBinaryName("linux", "x64");
expect(R.isOk(result)).toBe(true);
if (R.isOk(result)) {
expect(result.data).toBe("wt-linux-x64");
}
});
});

describe("tryRemoveMacosQuarantine", () => {
test("remover ok → no warning", () => {
const warnings: string[] = [];
const remover: QuarantineRemover = () => R.ok(undefined);

tryRemoveMacosQuarantine("/path/to/wt", {
remover,
warn: (m) => warnings.push(m),
});

expect(warnings).toEqual([]);
});

test("remover errs → warning logged, no throw", () => {
const warnings: string[] = [];
const remover: QuarantineRemover = () => R.err(new Error("xattr: command not found"));

expect(() =>
tryRemoveMacosQuarantine("/path/to/wt", {
remover,
warn: (m) => warnings.push(m),
}),
).not.toThrow();

expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("xattr: command not found");
expect(warnings[0]).toContain("/path/to/wt");
});

test("remover throws synchronously → warning logged, no throw", () => {
const warnings: string[] = [];
const remover: QuarantineRemover = () => {
throw new Error("boom from a buggy remover");
};

expect(() =>
tryRemoveMacosQuarantine("/usr/local/bin/wt", {
remover,
warn: (m) => warnings.push(m),
}),
).not.toThrow();

expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("boom from a buggy remover");
expect(warnings[0]).toContain("/usr/local/bin/wt");
});

test("remover throws a non-Error → message still surfaces", () => {
const warnings: string[] = [];
const remover: QuarantineRemover = () => {
throw "raw string failure";
};

expect(() =>
tryRemoveMacosQuarantine("/usr/local/bin/wt", {
remover,
warn: (m) => warnings.push(m),
}),
).not.toThrow();

expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("raw string failure");
});
});
95 changes: 74 additions & 21 deletions src/cli/commands/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import { UPDATE_CHECK_FILENAME } from "../update-notifier.ts";

const REPO = "epodivilov/worktree-kit";

function detectBinaryName(): Result<string> {
const platform = process.platform;
const arch = process.arch;
export const WINDOWS_UNSUPPORTED_MESSAGE = "Windows is not supported by self-update; reinstall via install.ps1";

export function detectBinaryName(platform: NodeJS.Platform, arch: string): Result<string> {
if (platform === "win32") {
return R.err(new Error(WINDOWS_UNSUPPORTED_MESSAGE));
}

const os = platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : null;
const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : null;
Expand All @@ -27,16 +30,65 @@ function detectBinaryName(): Result<string> {
return R.ok(`wt-${os}-${cpu}`);
}

/**
* Best-effort removal of the macOS quarantine attribute from a freshly
* downloaded binary. Failures (missing `xattr`, non-zero exit, or no
* attribute present) must NOT fail the self-update — they only surface
* as a warning so the user knows why the binary might be Gatekeeper-blocked.
*/
export type QuarantineRemover = (targetPath: string) => Result<void>;

export const defaultQuarantineRemover: QuarantineRemover = (targetPath) => {
try {
const proc = Bun.spawnSync(["xattr", "-d", "com.apple.quarantine", targetPath]);
if (proc.exitCode !== 0) {
const stderr = proc.stderr?.toString().trim();
return R.err(new Error(stderr || `xattr exited with code ${proc.exitCode}`));
}
return R.ok(undefined);
} catch (err) {
return R.err(err instanceof Error ? err : new Error(String(err)));
}
};

export function tryRemoveMacosQuarantine(
targetPath: string,
deps: { remover: QuarantineRemover; warn: (message: string) => void },
): void {
let result: Result<void>;
try {
result = deps.remover(targetPath);
} catch (err) {
// Defensive: the contract says the remover returns a Result, but if an
// injected/buggy remover throws synchronously, we must still degrade to
// a warning rather than fail the update.
result = R.err(err instanceof Error ? err : new Error(String(err)));
}
if (R.isErr(result)) {
deps.warn(
`Could not remove macOS quarantine attribute (${result.error.message}); macOS Gatekeeper may block the new binary until you run \`xattr -d com.apple.quarantine ${targetPath}\` manually.`,
);
}
}

function formatMb(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1);
}

interface DownloadBinaryDeps {
platform: NodeJS.Platform;
quarantineRemover: QuarantineRemover;
warn: (message: string) => void;
onProgress?: (downloaded: number, total: number) => void;
}

async function downloadBinary(
tag: string,
binaryName: string,
targetPath: string,
onProgress?: (downloaded: number, total: number) => void,
deps: DownloadBinaryDeps,
): Promise<Result<void>> {
const { platform, quarantineRemover, warn, onProgress } = deps;
const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;

let res: Response;
Expand Down Expand Up @@ -86,12 +138,8 @@ async function downloadBinary(
await chmod(tmpPath, 0o755);
await rename(tmpPath, targetPath);

if (process.platform === "darwin") {
try {
Bun.spawnSync(["xattr", "-d", "com.apple.quarantine", targetPath]);
} catch {
// ignore — quarantine attribute may not exist
}
if (platform === "darwin") {
tryRemoveMacosQuarantine(targetPath, { remover: quarantineRemover, warn });
}
} catch (err) {
return R.err(new Error(`Post-download setup failed: ${err instanceof Error ? err.message : String(err)}`));
Expand Down Expand Up @@ -133,7 +181,7 @@ export function selfUpdateCommand(container: Container) {

spinner.message(`Downloading ${latest.tag}...`);

const binaryResult = detectBinaryName();
const binaryResult = detectBinaryName(process.platform, process.arch);
if (R.isErr(binaryResult)) {
spinner.stop(pc.red("Failed"));
throw new CommandError(binaryResult.error.message, EXIT_FAILURE);
Expand All @@ -143,16 +191,21 @@ export function selfUpdateCommand(container: Container) {
const execPath = process.execPath;

let lastRender = 0;
const downloadResult = await downloadBinary(latest.tag, binaryName, execPath, (downloaded, total) => {
const now = Date.now();
if (now - lastRender < 200) return;
lastRender = now;
const current = formatMb(downloaded);
const message =
total > 0
? `Downloading ${latest.tag}... ${current}/${formatMb(total)} MB`
: `Downloading ${latest.tag}... ${current} MB`;
spinner.message(message);
const downloadResult = await downloadBinary(latest.tag, binaryName, execPath, {
platform: process.platform,
quarantineRemover: defaultQuarantineRemover,
warn: (message) => ui.warn(message),
onProgress: (downloaded, total) => {
const now = Date.now();
if (now - lastRender < 200) return;
lastRender = now;
const current = formatMb(downloaded);
const message =
total > 0
? `Downloading ${latest.tag}... ${current}/${formatMb(total)} MB`
: `Downloading ${latest.tag}... ${current} MB`;
spinner.message(message);
},
});
if (R.isErr(downloadResult)) {
spinner.stop(pc.red("Failed"));
Expand Down