Skip to content

Commit d8bcb5d

Browse files
committed
polyfill for zip
1 parent e4be408 commit d8bcb5d

8 files changed

Lines changed: 121 additions & 102 deletions

File tree

apps/twig/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"dotenv": "^17.2.3",
142142
"electron-log": "^5.4.3",
143143
"electron-store": "^11.0.0",
144+
"fflate": "^0.8.2",
144145
"file-icon": "^6.0.0",
145146
"framer-motion": "^12.26.2",
146147
"fzf": "^0.5.2",
@@ -166,11 +167,11 @@
166167
"remark-breaks": "^4.0.0",
167168
"remark-gfm": "^4.0.1",
168169
"sonner": "^2.0.7",
170+
"striptags": "^3.2.0",
169171
"tippy.js": "^6.3.7",
170172
"uuid": "^9.0.1",
171173
"vscode-icons-js": "^11.6.1",
172174
"zod": "^4.1.12",
173-
"zustand": "^4.5.0",
174-
"striptags": "^3.2.0"
175+
"zustand": "^4.5.0"
175176
}
176177
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import { dirname, join } from "node:path";
3+
import { unzipSync } from "fflate";
4+
5+
/**
6+
* Extracts a ZIP file to a directory using fflate (cross-platform, no native dependencies).
7+
*/
8+
export async function extractZip(
9+
zipPath: string,
10+
extractDir: string,
11+
): Promise<void> {
12+
const data = await readFile(zipPath);
13+
const unzipped = unzipSync(new Uint8Array(data));
14+
for (const [filename, content] of Object.entries(unzipped)) {
15+
const fullPath = join(extractDir, filename);
16+
if (filename.endsWith("/")) {
17+
await mkdir(fullPath, { recursive: true });
18+
} else {
19+
await mkdir(dirname(fullPath), { recursive: true });
20+
await writeFile(fullPath, content);
21+
}
22+
}
23+
}

apps/twig/src/main/services/posthog-plugin/service.test.ts

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const mockNet = vi.hoisted(() => ({
1616
fetch: vi.fn(),
1717
}));
1818

19-
const mockExecFileAsync = vi.hoisted(() =>
20-
vi.fn<(cmd: string, args: string[]) => Promise<unknown>>(async () => {}),
19+
const mockExtractZip = vi.hoisted(() =>
20+
vi.fn<(zipPath: string, extractDir: string) => Promise<void>>(async () => {}),
2121
);
2222

2323
vi.mock("electron", () => ({
@@ -35,14 +35,8 @@ vi.mock("node:fs/promises", async () => {
3535
return { ...fs.promises, default: fs.promises };
3636
});
3737

38-
vi.mock("node:child_process", () => ({
39-
execFile: vi.fn(),
40-
default: { execFile: vi.fn() },
41-
}));
42-
43-
vi.mock("node:util", () => ({
44-
promisify: () => mockExecFileAsync,
45-
default: { promisify: () => mockExecFileAsync },
38+
vi.mock("../../lib/extract-zip.js", () => ({
39+
extractZip: mockExtractZip,
4640
}));
4741

4842
vi.mock("node:os", () => ({
@@ -82,21 +76,19 @@ function mockFetchResponse(ok: boolean, status = 200) {
8276
};
8377
}
8478

85-
/** Simulate unzip by creating skill files in the extracted dir */
86-
function simulateUnzip() {
87-
mockExecFileAsync.mockImplementation(async (_cmd: string, args: string[]) => {
88-
const dIdx = args.indexOf("-d");
89-
if (dIdx >= 0) {
90-
const extractDir = args[dIdx + 1];
79+
/** Simulate zip extraction by creating skill files in the extracted dir */
80+
function simulateExtractZip() {
81+
mockExtractZip.mockImplementation(
82+
async (_zipPath: string, extractDir: string) => {
9183
vol.mkdirSync(`${extractDir}/skills/remote-skill`, {
9284
recursive: true,
9385
});
9486
vol.writeFileSync(
9587
`${extractDir}/skills/remote-skill/SKILL.md`,
9688
"# Remote",
9789
);
98-
}
99-
});
90+
},
91+
);
10092
}
10193

10294
/** Create the bundled plugin directory in memfs */
@@ -116,7 +108,7 @@ describe("PosthogPluginService", () => {
116108

117109
mockApp.isPackaged = false;
118110
mockNet.fetch.mockResolvedValue(mockFetchResponse(true));
119-
mockExecFileAsync.mockResolvedValue({});
111+
mockExtractZip.mockResolvedValue(undefined);
120112

121113
service = new PosthogPluginService();
122114
});
@@ -204,7 +196,7 @@ describe("PosthogPluginService", () => {
204196
describe("updateSkills", () => {
205197
it("downloads, extracts, and installs skills", async () => {
206198
setupBundledPlugin();
207-
simulateUnzip();
199+
simulateExtractZip();
208200

209201
await service.updateSkills();
210202

@@ -215,10 +207,7 @@ describe("PosthogPluginService", () => {
215207
expect(mockNet.fetch).toHaveBeenCalledWith(
216208
"https://example.com/skills.zip",
217209
);
218-
expect(mockExecFileAsync).toHaveBeenCalledWith(
219-
"unzip",
220-
expect.arrayContaining(["-o"]),
221-
);
210+
expect(mockExtractZip).toHaveBeenCalled();
222211
});
223212

224213
it("performs atomic swap of skills directory", async () => {
@@ -227,7 +216,7 @@ describe("PosthogPluginService", () => {
227216
vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/old-skill`, { recursive: true });
228217
vol.writeFileSync(`${RUNTIME_SKILLS_DIR}/old-skill/SKILL.md`, "# Old");
229218

230-
simulateUnzip();
219+
simulateExtractZip();
231220
await service.updateSkills();
232221

233222
// New skill should be present, old skill should be gone
@@ -245,7 +234,7 @@ describe("PosthogPluginService", () => {
245234
vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true });
246235
vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}");
247236

248-
simulateUnzip();
237+
simulateExtractZip();
249238
await service.updateSkills();
250239

251240
expect(
@@ -254,7 +243,7 @@ describe("PosthogPluginService", () => {
254243
});
255244

256245
it("emits 'updated' event on success", async () => {
257-
simulateUnzip();
246+
simulateExtractZip();
258247
const handler = vi.fn();
259248
service.on("skillsUpdated", handler);
260249

@@ -264,7 +253,7 @@ describe("PosthogPluginService", () => {
264253
});
265254

266255
it("throttles: skips if called within 30 minutes", async () => {
267-
simulateUnzip();
256+
simulateExtractZip();
268257
await service.updateSkills();
269258
mockNet.fetch.mockClear();
270259

@@ -274,7 +263,7 @@ describe("PosthogPluginService", () => {
274263
});
275264

276265
it("allows update after throttle period expires", async () => {
277-
simulateUnzip();
266+
simulateExtractZip();
278267
await service.updateSkills();
279268
mockNet.fetch.mockClear();
280269

@@ -319,15 +308,11 @@ describe("PosthogPluginService", () => {
319308
});
320309

321310
it("handles missing skills dir in archive", async () => {
322-
// Unzip creates no skills directory
323-
mockExecFileAsync.mockImplementation(
324-
async (_cmd: string, args: string[]) => {
325-
const dIdx = args.indexOf("-d");
326-
if (dIdx >= 0) {
327-
const extractDir = args[dIdx + 1];
328-
vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true });
329-
vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope");
330-
}
311+
// Extraction creates no skills directory
312+
mockExtractZip.mockImplementation(
313+
async (_zipPath: string, extractDir: string) => {
314+
vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true });
315+
vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope");
331316
},
332317
);
333318

@@ -339,7 +324,7 @@ describe("PosthogPluginService", () => {
339324
});
340325

341326
it("cleans up temp dir even on error", async () => {
342-
mockExecFileAsync.mockRejectedValue(new Error("unzip failed"));
327+
mockExtractZip.mockRejectedValue(new Error("extraction failed"));
343328

344329
await service.updateSkills();
345330

apps/twig/src/main/services/posthog-plugin/update-skills-saga.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { execFile } from "node:child_process";
21
import { existsSync } from "node:fs";
32
import { cp, mkdir, readdir, rename, rm } from "node:fs/promises";
43
import { join } from "node:path";
5-
import { promisify } from "node:util";
4+
import { extractZip } from "@main/lib/extract-zip.js";
65
import { Saga } from "@posthog/shared";
76

8-
const execFileAsync = promisify(execFile);
9-
107
/**
118
* Overlays previously-downloaded skills on top of the runtime plugin dir.
129
* Each skill directory in the cache replaces the same-named one in the plugin.
@@ -192,7 +189,7 @@ export class UpdateSkillsSaga extends Saga<
192189

193190
const extractDir = join(tempDir, "extracted");
194191
await mkdir(extractDir, { recursive: true });
195-
await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]);
192+
await extractZip(zipPath, extractDir);
196193

197194
const skillsSource = await this.findSkillsDir(extractDir);
198195
if (!skillsSource) {

apps/twig/vite.main.config.mts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import {
44
cpSync,
55
existsSync,
66
mkdirSync,
7+
readFileSync,
78
readdirSync,
89
statSync,
910
} from "node:fs";
10-
import { cp, mkdir, readdir, rm } from "node:fs/promises";
11+
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
1112
import { tmpdir } from "node:os";
12-
import path, { join } from "node:path";
13+
import path, { dirname, join } from "node:path";
1314
import { fileURLToPath } from "node:url";
1415
import { promisify } from "node:util";
16+
import { unzipSync } from "fflate";
1517
import { defineConfig, loadEnv, type Plugin } from "vite";
1618
import tsconfigPaths from "vite-tsconfig-paths";
1719
import {
@@ -169,7 +171,17 @@ async function downloadAndExtractSkills(targetDir: string): Promise<boolean> {
169171
// Extract
170172
const extractDir = join(tempDir, "extracted");
171173
await mkdir(extractDir, { recursive: true });
172-
await execFileAsync("unzip", ["-o", zipPath, "-d", extractDir]);
174+
const zipData = readFileSync(zipPath);
175+
const unzipped = unzipSync(new Uint8Array(zipData));
176+
for (const [filename, content] of Object.entries(unzipped)) {
177+
const fullPath = join(extractDir, filename);
178+
if (filename.endsWith("/")) {
179+
await mkdir(fullPath, { recursive: true });
180+
} else {
181+
await mkdir(dirname(fullPath), { recursive: true });
182+
await writeFile(fullPath, content);
183+
}
184+
}
173185

174186
// Find skills directory in extracted content
175187
const skillsSource = await findSkillsDirInExtract(extractDir);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"devDependencies": {
5757
"@biomejs/biome": "2.2.4",
5858
"@posthog/cli": "^0.5.26",
59+
"fflate": "^0.8.2",
5960
"husky": "^9.1.7",
6061
"knip": "^5.66.3",
6162
"lint-staged": "^15.5.2",

0 commit comments

Comments
 (0)