Skip to content

Commit 600ae01

Browse files
committed
Fix update file write
1 parent 5766717 commit 600ae01

File tree

5 files changed

+198
-17
lines changed

5 files changed

+198
-17
lines changed

.github/workflows/release.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,22 @@ permissions:
99
contents: write
1010

1111
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: oven-sh/setup-bun@v2
19+
with:
20+
bun-version: latest
21+
22+
- run: bun install
23+
24+
- run: bun test
25+
1226
build:
27+
needs: test
1328
strategy:
1429
matrix:
1530
include:

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"name": "polar-cli",
3-
"version": "1.3.1",
3+
"version": "1.3.2",
44
"description": "",
55
"bin": "bin/cli.js",
66
"type": "module",
77
"scripts": {
88
"build": "tsup ./src/cli.ts --format esm --outDir bin",
99
"dev": "tsc --watch",
10-
"test": "echo \"Error: no test specified\" && exit 1",
10+
"test": "bun test",
1111
"check": "biome check --write ./src",
1212
"build:binary": "bun build ./src/cli.ts --compile --outfile polar",
1313
"build:binary:darwin-arm64": "bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile polar",

src/commands/update.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2+
import { mkdtemp, rm, writeFile, readFile, stat } from "fs/promises";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
5+
import { Effect } from "effect";
6+
import { replaceBinary } from "./update";
7+
8+
async function makeTemp() {
9+
return mkdtemp(join(tmpdir(), "polar-test-"));
10+
}
11+
12+
describe("replaceBinary", () => {
13+
let dir: string;
14+
let newBinaryPath: string;
15+
let binaryPath: string;
16+
17+
beforeEach(async () => {
18+
dir = await makeTemp();
19+
newBinaryPath = join(dir, "polar-new");
20+
binaryPath = join(dir, "polar");
21+
await writeFile(newBinaryPath, "#!/bin/sh\necho new");
22+
await writeFile(binaryPath, "#!/bin/sh\necho old");
23+
});
24+
25+
afterEach(async () => {
26+
await rm(dir, { recursive: true, force: true });
27+
});
28+
29+
test("replaces target binary with new binary content", async () => {
30+
await Effect.runPromise(replaceBinary(newBinaryPath, binaryPath));
31+
32+
const content = await readFile(binaryPath, "utf8");
33+
expect(content).toBe("#!/bin/sh\necho new");
34+
});
35+
36+
test("sets executable permissions on target binary", async () => {
37+
await Effect.runPromise(replaceBinary(newBinaryPath, binaryPath));
38+
39+
const s = await stat(binaryPath);
40+
// check owner execute bit
41+
expect(s.mode & 0o111).toBeGreaterThan(0);
42+
});
43+
44+
test("leaves no temp file behind after success", async () => {
45+
await Effect.runPromise(replaceBinary(newBinaryPath, binaryPath));
46+
47+
// list files in dir — only the replaced binary should remain
48+
const { readdir } = await import("fs/promises");
49+
const files = await readdir(dir);
50+
const tempFiles = files.filter((f) => f.startsWith(".polar-update-"));
51+
expect(tempFiles).toHaveLength(0);
52+
});
53+
54+
test("throws and cleans up temp file on non-EACCES write error", async () => {
55+
// Simulate a generic I/O error during Bun.write (not EACCES)
56+
const bunSpy = spyOn(Bun, "write").mockImplementationOnce(() =>
57+
Promise.reject(new Error("EIO: input/output error")),
58+
);
59+
60+
await expect(
61+
Effect.runPromise(replaceBinary(newBinaryPath, binaryPath)),
62+
).rejects.toThrow("EIO");
63+
64+
const { readdir } = await import("fs/promises");
65+
const files = await readdir(dir);
66+
const tempFiles = files.filter((f) => f.startsWith(".polar-update-"));
67+
expect(tempFiles).toHaveLength(0);
68+
69+
bunSpy.mockRestore();
70+
});
71+
72+
test("does not throw when EACCES triggers sudo fallback", async () => {
73+
// Simulate EACCES on rename by mocking Bun.write to throw it
74+
const bunSpy = spyOn(Bun, "write").mockImplementationOnce(() => {
75+
const err: any = new Error("EACCES: permission denied");
76+
err.code = "EACCES";
77+
return Promise.reject(err);
78+
});
79+
80+
// Mock Bun.spawn so sudo mv appears to succeed
81+
const spawnSpy = spyOn(Bun, "spawn").mockImplementationOnce(() => ({
82+
exited: Promise.resolve(0),
83+
}));
84+
85+
await Effect.runPromise(replaceBinary(newBinaryPath, binaryPath));
86+
87+
// Verify sudo mv was called with the right args
88+
expect(spawnSpy).toHaveBeenCalledWith(
89+
["sudo", "mv", newBinaryPath, binaryPath],
90+
expect.objectContaining({ stdin: "inherit" }),
91+
);
92+
93+
bunSpy.mockRestore();
94+
spawnSpy.mockRestore();
95+
});
96+
97+
test("throws when sudo mv exits non-zero", async () => {
98+
const bunSpy = spyOn(Bun, "write").mockImplementationOnce(() => {
99+
const err: any = new Error("EACCES: permission denied");
100+
err.code = "EACCES";
101+
return Promise.reject(err);
102+
});
103+
104+
const spawnSpy = spyOn(Bun, "spawn").mockImplementationOnce(() => ({
105+
exited: Promise.resolve(1),
106+
}));
107+
108+
await expect(
109+
Effect.runPromise(replaceBinary(newBinaryPath, binaryPath)),
110+
).rejects.toThrow("sudo mv failed");
111+
112+
bunSpy.mockRestore();
113+
spawnSpy.mockRestore();
114+
});
115+
});

src/commands/update.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,72 @@
11
import { Command } from "@effect/cli";
22
import { Console, Effect, Schema } from "effect";
33
import { createHash } from "crypto";
4-
import { chmod, mkdtemp, rm } from "fs/promises";
4+
import { chmod, mkdtemp, rename, rm, unlink } from "fs/promises";
55
import { tmpdir } from "os";
6-
import { join } from "path";
6+
import { dirname, join } from "path";
77
import { VERSION } from "../version";
88

9+
const fsError = (e: unknown): Error =>
10+
Object.assign(
11+
new Error(e instanceof Error ? e.message : String(e)),
12+
{ code: (e as any)?.code },
13+
);
14+
15+
export const replaceBinary = (
16+
newBinaryPath: string,
17+
binaryPath: string,
18+
): Effect.Effect<void, Error> =>
19+
Effect.gen(function* () {
20+
yield* Effect.tryPromise({
21+
try: () => chmod(newBinaryPath, 0o755),
22+
catch: () => new Error("Failed to chmod new binary"),
23+
});
24+
25+
const tempPath = join(dirname(binaryPath), `.polar-update-${Date.now()}`);
26+
27+
yield* Effect.gen(function* () {
28+
const newBinary = yield* Effect.tryPromise({
29+
try: () => Bun.file(newBinaryPath).arrayBuffer(),
30+
catch: fsError,
31+
});
32+
yield* Effect.tryPromise({
33+
try: () => Bun.write(tempPath, newBinary),
34+
catch: fsError,
35+
});
36+
yield* Effect.tryPromise({
37+
try: () => rename(tempPath, binaryPath),
38+
catch: fsError,
39+
});
40+
}).pipe(
41+
Effect.tapError(() =>
42+
Effect.promise(() => unlink(tempPath).catch(() => {})),
43+
),
44+
Effect.catchAll((e: Error) =>
45+
(e as any)?.code === "EACCES"
46+
? Effect.gen(function* () {
47+
const proc = Bun.spawn(["sudo", "mv", newBinaryPath, binaryPath], {
48+
stdout: "inherit",
49+
stderr: "inherit",
50+
stdin: "inherit",
51+
});
52+
const exitCode = yield* Effect.tryPromise({
53+
try: () => proc.exited,
54+
catch: () => new Error("Failed to run sudo mv"),
55+
});
56+
if (exitCode !== 0) {
57+
return yield* Effect.fail(new Error("sudo mv failed"));
58+
}
59+
})
60+
: Effect.fail(e),
61+
),
62+
);
63+
64+
yield* Effect.tryPromise({
65+
try: () => chmod(binaryPath, 0o755),
66+
catch: () => new Error("Failed to chmod binary"),
67+
});
68+
});
69+
970
const REPO = "polarsource/cli";
1071

1172
const GitHubRelease = Schema.Struct({
@@ -137,7 +198,7 @@ const downloadAndUpdate = (
137198
}
138199

139200
const archiveData = yield* Effect.tryPromise({
140-
try: () => Bun.file(archivePath).arrayBuffer(),
201+
try: () => Bun.file(archivePath).arrayBuffer() as Promise<ArrayBuffer>,
141202
catch: () => new Error("Failed to read archive for checksum"),
142203
});
143204

@@ -180,17 +241,7 @@ const downloadAndUpdate = (
180241

181242
yield* Console.log(`${dim}Replacing binary...${reset}`);
182243

183-
yield* Effect.tryPromise({
184-
try: async () => {
185-
const newBinary = await Bun.file(newBinaryPath).arrayBuffer();
186-
await Bun.write(binaryPath, newBinary);
187-
await chmod(binaryPath, 0o755);
188-
},
189-
catch: (e) =>
190-
new Error(
191-
`Failed to replace binary: ${e instanceof Error ? e.message : e}`,
192-
),
193-
});
244+
yield* replaceBinary(newBinaryPath, binaryPath);
194245

195246
yield* Console.log("");
196247
yield* Console.log(

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const VERSION = "v1.3.1";
1+
export const VERSION = "v1.3.2";

0 commit comments

Comments
 (0)