@involvex/ext-cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1115 @@
1
+ # ext-cli Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a Bun-based CLI tool (`ext-cli`) that downloads Chrome/Edge extensions from their respective web stores, extracts CRX files to directories, and packs directories into CRX files.
6
+
7
+ **Architecture:** Single-package Bun project with TypeScript. CLI uses `commander` for argument parsing. Core logic split into three modules: `downloader` (fetch CRX from store), `extractor` (CRX → directory), `packer` (directory → CRX). CRX parsing handles both v2 and v3 formats. Edge support via separate download URL pattern.
8
+
9
+ **Tech Stack:** Bun runtime, TypeScript, commander (CLI args), `@tomjs/unzip-crx` (CRX extraction), `node-forge` (RSA key generation for packing), protobufjs (CRX3 header parsing)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ chrome-ext-manager/
17
+ ├── package.json
18
+ ├── tsconfig.json
19
+ ├── src/
20
+ │ ├── cli.ts # CLI entry point, argument parsing
21
+ │ ├── index.ts # Public API exports
22
+ │ ├── downloader.ts # Download CRX from Chrome/Edge store
23
+ │ ├── extractor.ts # Extract CRX file to directory
24
+ │ ├── packer.ts # Pack directory into CRX file
25
+ │ ├── crx-parser.ts # CRX v2/v3 binary format parsing
26
+ │ ├── url-parser.ts # Parse store URLs → extension IDs
27
+ │ └── utils.ts # Shared helpers (file ops, progress)
28
+ ├── test/
29
+ │ ├── crx-parser.test.ts
30
+ │ ├── url-parser.test.ts
31
+ │ ├── extractor.test.ts
32
+ │ ├── packer.test.ts
33
+ │ └── fixtures/
34
+ │ ├── sample.crx # Small test CRX file
35
+ │ └── extension-dir/ # Test extension directory
36
+ │ └── manifest.json
37
+ └── docs/
38
+ └── superpowers/
39
+ └── plans/
40
+ └── 2026-07-05-ext-cli.md
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Global Constraints
46
+
47
+ - Runtime: Bun >= 1.3.0
48
+ - Language: TypeScript (strict mode)
49
+ - Package manager: bun (not npm/yarn/pnpm)
50
+ - Shell commands: PowerShell-compatible (Windows)
51
+ - CLI binary name: `ext-cli`
52
+ - Auto-generate .pem keys when packing
53
+ - Support both Chrome Web Store and Edge Add-ons URLs
54
+
55
+ ---
56
+
57
+ ### Task 1: Project Scaffolding
58
+
59
+ **Files:**
60
+ - Create: `package.json`
61
+ - Create: `tsconfig.json`
62
+ - Create: `src/cli.ts` (minimal)
63
+ - Create: `src/index.ts` (minimal)
64
+
65
+ **Interfaces:**
66
+ - Consumes: none
67
+ - Produces: project structure ready for development
68
+
69
+ - [ ] **Step 1: Initialize Bun project**
70
+
71
+ ```bash
72
+ cd D:\repos\chrome-ext-manager
73
+ bun init -y
74
+ ```
75
+
76
+ - [ ] **Step 2: Install dependencies**
77
+
78
+ ```bash
79
+ bun add commander node-forge
80
+ bun add -d @types/node @types/bun typescript
81
+ ```
82
+
83
+ - [ ] **Step 3: Create tsconfig.json**
84
+
85
+ ```json
86
+ {
87
+ "compilerOptions": {
88
+ "target": "ESNext",
89
+ "module": "ESNext",
90
+ "moduleResolution": "bundler",
91
+ "strict": true,
92
+ "esModuleInterop": true,
93
+ "skipLibCheck": true,
94
+ "outDir": "dist",
95
+ "rootDir": "src",
96
+ "declaration": true,
97
+ "resolveJsonModule": true,
98
+ "types": ["bun-types"]
99
+ },
100
+ "include": ["src/**/*"],
101
+ "exclude": ["node_modules", "dist", "test"]
102
+ }
103
+ ```
104
+
105
+ - [ ] **Step 4: Create minimal src/cli.ts**
106
+
107
+ ```typescript
108
+ #!/usr/bin/env bun
109
+
110
+ console.log("ext-cli - Chrome/Edge Extension Manager");
111
+ ```
112
+
113
+ - [ ] **Step 5: Create minimal src/index.ts**
114
+
115
+ ```typescript
116
+ export { downloadExtension } from "./downloader";
117
+ export { extractCrx } from "./extractor";
118
+ export { packCrx } from "./packer";
119
+ ```
120
+
121
+ - [ ] **Step 6: Add scripts to package.json**
122
+
123
+ Add to `package.json`:
124
+ ```json
125
+ {
126
+ "name": "ext-cli",
127
+ "version": "1.0.0",
128
+ "type": "module",
129
+ "bin": {
130
+ "ext-cli": "./src/cli.ts"
131
+ },
132
+ "scripts": {
133
+ "build": "bun build src/cli.ts --compile --outfile ext-cli",
134
+ "dev": "bun run src/cli.ts",
135
+ "test": "bun test"
136
+ }
137
+ }
138
+ ```
139
+
140
+ - [ ] **Step 7: Commit**
141
+
142
+ ```bash
143
+ git add -A
144
+ git commit -m "feat: scaffold ext-cli project with bun, typescript, commander"
145
+ ```
146
+
147
+ ---
148
+
149
+ ### Task 2: URL Parser Module
150
+
151
+ **Files:**
152
+ - Create: `src/url-parser.ts`
153
+ - Create: `test/url-parser.test.ts`
154
+
155
+ **Interfaces:**
156
+ - Consumes: none
157
+ - Produces: `parseStoreUrl(url: string): StoreInfo` where `StoreInfo = { id: string; store: 'chrome' | 'edge' }`
158
+
159
+ - [ ] **Step 1: Write failing tests**
160
+
161
+ ```typescript
162
+ // test/url-parser.test.ts
163
+ import { describe, test, expect } from "bun:test";
164
+ import { parseStoreUrl } from "../src/url-parser";
165
+
166
+ describe("parseStoreUrl", () => {
167
+ test("parses Chrome Web Store URL", () => {
168
+ const result = parseStoreUrl(
169
+ "https://chromewebstore.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm"
170
+ );
171
+ expect(result).toEqual({ id: "cjpalhdlnbpafiamejdnhcphjbkeiagm", store: "chrome" });
172
+ });
173
+
174
+ test("parses Chrome URL with query params", () => {
175
+ const result = parseStoreUrl(
176
+ "https://chromewebstore.google.com/detail/ext-name/abcdef1234567890abcdef12?hl=en"
177
+ );
178
+ expect(result).toEqual({ id: "abcdef1234567890abcdef12", store: "chrome" });
179
+ });
180
+
181
+ test("parses Edge Add-ons URL", () => {
182
+ const result = parseStoreUrl(
183
+ "https://microsoftedge.microsoft.com/addons/detail/ublock-origin/odfafepnkmbhccpbejgmiehpchacaeak"
184
+ );
185
+ expect(result).toEqual({ id: "odfafepnkmbhccpbejgmiehpchacaeak", store: "edge" });
186
+ });
187
+
188
+ test("parses bare 32-char extension ID as chrome", () => {
189
+ const result = parseStoreUrl("cjpalhdlnbpafiamejdnhcphjbkeiagm");
190
+ expect(result).toEqual({ id: "cjpalhdlnbpafiamejdnhcphjbkeiagm", store: "chrome" });
191
+ });
192
+
193
+ test("throws on invalid input", () => {
194
+ expect(() => parseStoreUrl("")).toThrow();
195
+ expect(() => parseStoreUrl("not-a-valid-id!")).toThrow();
196
+ });
197
+ });
198
+ ```
199
+
200
+ - [ ] **Step 2: Run tests to verify they fail**
201
+
202
+ ```bash
203
+ bun test test/url-parser.test.ts
204
+ ```
205
+ Expected: FAIL — `parseStoreUrl` not found
206
+
207
+ - [ ] **Step 3: Implement url-parser.ts**
208
+
209
+ ```typescript
210
+ // src/url-parser.ts
211
+
212
+ export interface StoreInfo {
213
+ id: string;
214
+ store: "chrome" | "edge";
215
+ }
216
+
217
+ const CHROME_URL_RE =
218
+ /chromewebstore\.google\.com\/webstore\/detail\/[^/]+\/([a-z]{32})/i;
219
+ const EDGE_URL_RE =
220
+ /microsoftedge\.microsoft\.com\/addons\/detail\/([a-z]{32})/i;
221
+ const BARE_ID_RE = /^[a-z]{32}$/i;
222
+
223
+ export function parseStoreUrl(input: string): StoreInfo {
224
+ const trimmed = input.trim();
225
+
226
+ // Try Chrome Web Store URL
227
+ const chromeMatch = trimmed.match(CHROME_URL_RE);
228
+ if (chromeMatch) {
229
+ return { id: chromeMatch[1].toLowerCase(), store: "chrome" };
230
+ }
231
+
232
+ // Try Edge Add-ons URL
233
+ const edgeMatch = trimmed.match(EDGE_URL_RE);
234
+ if (edgeMatch) {
235
+ return { id: edgeMatch[1].toLowerCase(), store: "edge" };
236
+ }
237
+
238
+ // Try bare extension ID (32 lowercase letters)
239
+ if (BARE_ID_RE.test(trimmed)) {
240
+ return { id: trimmed.toLowerCase(), store: "chrome" };
241
+ }
242
+
243
+ throw new Error(
244
+ `Invalid extension URL or ID: "${input}". Expected a Chrome Web Store URL, Edge Add-ons URL, or a 32-character extension ID.`
245
+ );
246
+ }
247
+ ```
248
+
249
+ - [ ] **Step 4: Run tests to verify they pass**
250
+
251
+ ```bash
252
+ bun test test/url-parser.test.ts
253
+ ```
254
+ Expected: ALL PASS
255
+
256
+ - [ ] **Step 5: Commit**
257
+
258
+ ```bash
259
+ git add src/url-parser.ts test/url-parser.test.ts
260
+ git commit -m "feat: add URL parser for Chrome/Edge store URLs and bare IDs"
261
+ ```
262
+
263
+ ---
264
+
265
+ ### Task 3: CRX Parser Module
266
+
267
+ **Files:**
268
+ - Create: `src/crx-parser.ts`
269
+ - Create: `test/crx-parser.test.ts`
270
+
271
+ **Interfaces:**
272
+ - Consumes: none
273
+ - Produces: `parseCrxHeader(buffer: Buffer): CrxInfo` and `extractZipFromCrx(buffer: Buffer): Buffer`
274
+
275
+ - [ ] **Step 1: Write failing tests**
276
+
277
+ ```typescript
278
+ // test/crx-parser.test.ts
279
+ import { describe, test, expect } from "bun:test";
280
+ import { parseCrxHeader, extractZipFromCrx } from "../src/crx-parser";
281
+ import { readFileSync } from "fs";
282
+ import { join } from "path";
283
+
284
+ describe("CRX Parser", () => {
285
+ test("parseCrxHeader reads CRX3 header", () => {
286
+ // Create a minimal CRX3 mock: Cr24 + version 3 + header_len + header bytes + zip
287
+ const header = Buffer.alloc(16); // 12 byte header + 4 byte dummy
288
+ header.write("Cr24", 0); // magic
289
+ header.writeUInt32LE(3, 4); // version
290
+ header.writeUInt32LE(4, 8); // header length (4 bytes of dummy)
291
+ header.writeUInt32LE(0, 12); // dummy header bytes
292
+
293
+ const info = parseCrxHeader(header);
294
+ expect(info.version).toBe(3);
295
+ expect(info.headerLength).toBe(4);
296
+ expect(info.zipOffset).toBe(16);
297
+ });
298
+
299
+ test("parseCrxHeader reads CRX2 header", () => {
300
+ const header = Buffer.alloc(16);
301
+ header.write("Cr24", 0); // magic
302
+ header.writeUInt32LE(2, 4); // version
303
+ header.writeUInt32LE(4, 8); // header length
304
+
305
+ const info = parseCrxHeader(header);
306
+ expect(info.version).toBe(2);
307
+ });
308
+
309
+ test("parseCrxHeader throws on invalid magic", () => {
310
+ const bad = Buffer.from("NOTACRX");
311
+ expect(() => parseCrxHeader(bad)).toThrow("Not a CRX file");
312
+ });
313
+
314
+ test("extractZipFromCrx returns zip bytes after header", () => {
315
+ // Build a fake CRX3: 12 byte fixed header + 4 byte protobuf header + "ZIPDATA"
316
+ const fixed = Buffer.alloc(12);
317
+ fixed.write("Cr24", 0);
318
+ fixed.writeUInt32LE(3, 4);
319
+ fixed.writeUInt32LE(4, 8);
320
+
321
+ const protoHeader = Buffer.alloc(4);
322
+ const zipData = Buffer.from("PKZIP_DATA_HERE");
323
+
324
+ const crx = Buffer.concat([fixed, protoHeader, zipData]);
325
+ const zip = extractZipFromCrx(crx);
326
+
327
+ expect(zip.equals(zipData)).toBe(true);
328
+ });
329
+ });
330
+ ```
331
+
332
+ - [ ] **Step 2: Run tests to verify they fail**
333
+
334
+ ```bash
335
+ bun test test/crx-parser.test.ts
336
+ ```
337
+ Expected: FAIL — `parseCrxHeader` not found
338
+
339
+ - [ ] **Step 3: Implement crx-parser.ts**
340
+
341
+ ```typescript
342
+ // src/crx-parser.ts
343
+
344
+ export interface CrxInfo {
345
+ version: number;
346
+ headerLength: number;
347
+ zipOffset: number;
348
+ }
349
+
350
+ const CRX_MAGIC = "Cr24";
351
+
352
+ /**
353
+ * Parse the CRX binary header (supports CRX2 and CRX3).
354
+ * Returns metadata about the header so callers can extract the ZIP payload.
355
+ */
356
+ export function parseCrxHeader(buffer: Buffer): CrxInfo {
357
+ if (buffer.length < 12) {
358
+ throw new Error("Not a CRX file: buffer too small");
359
+ }
360
+
361
+ const magic = buffer.toString("ascii", 0, 4);
362
+ if (magic !== CRX_MAGIC) {
363
+ throw new Error(`Not a CRX file: invalid magic "${magic}" (expected "${CRX_MAGIC}")`);
364
+ }
365
+
366
+ const version = buffer.readUInt32LE(4);
367
+ if (version !== 2 && version !== 3) {
368
+ throw new Error(`Unsupported CRX version: ${version}`);
369
+ }
370
+
371
+ const headerLength = buffer.readUInt32LE(8);
372
+ const zipOffset = 12 + headerLength;
373
+
374
+ return { version, headerLength, zipOffset };
375
+ }
376
+
377
+ /**
378
+ * Extract the ZIP payload from a CRX buffer.
379
+ * The ZIP starts immediately after the CRX header (fixed 12 bytes + header protobuf).
380
+ */
381
+ export function extractZipFromCrx(buffer: Buffer): Buffer {
382
+ const info = parseCrxHeader(buffer);
383
+ return buffer.subarray(info.zipOffset);
384
+ }
385
+ ```
386
+
387
+ - [ ] **Step 4: Run tests to verify they pass**
388
+
389
+ ```bash
390
+ bun test test/crx-parser.test.ts
391
+ ```
392
+ Expected: ALL PASS
393
+
394
+ - [ ] **Step 5: Commit**
395
+
396
+ ```bash
397
+ git add src/crx-parser.ts test/crx-parser.test.ts
398
+ git commit -m "feat: add CRX v2/v3 parser for header reading and ZIP extraction"
399
+ ```
400
+
401
+ ---
402
+
403
+ ### Task 4: Downloader Module
404
+
405
+ **Files:**
406
+ - Create: `src/downloader.ts`
407
+
408
+ **Interfaces:**
409
+ - Consumes: `parseStoreUrl` from `url-parser.ts`
410
+ - Produces: `downloadExtension(urlOrId: string, outputDir?: string): Promise<string>` — returns path to saved .crx file
411
+
412
+ - [ ] **Step 1: Implement downloader.ts**
413
+
414
+ ```typescript
415
+ // src/downloader.ts
416
+ import { parseStoreUrl } from "./url-parser";
417
+ import { mkdir, writeFile } from "fs/promises";
418
+ import { join } from "path";
419
+
420
+ const CHROME_CRX_URL =
421
+ "https://clients2.google.com/service/update2/crx?response=redirect&prodversion=131.0&acceptformat=crx2,crx3&x=id%3D{ID}%26uc";
422
+
423
+ const EDGE_CRX_URL =
424
+ "https://edge.microsoft.com/extensionwebstorebase/v1/crx?response=redirect&x=id%3D{ID}%26installsource%3Dondemand%26uc";
425
+
426
+ function buildDownloadUrl(id: string, store: "chrome" | "edge"): string {
427
+ const template = store === "chrome" ? CHROME_CRX_URL : EDGE_CRX_URL;
428
+ return template.replace("{ID}", id);
429
+ }
430
+
431
+ /**
432
+ * Download a CRX file from the Chrome Web Store or Edge Add-ons.
433
+ * Accepts a full store URL or a bare 32-char extension ID.
434
+ * Returns the path to the saved .crx file.
435
+ */
436
+ export async function downloadExtension(
437
+ urlOrId: string,
438
+ outputDir: string = "."
439
+ ): Promise<string> {
440
+ const { id, store } = parseStoreUrl(urlOrId);
441
+ const downloadUrl = buildDownloadUrl(id, store);
442
+
443
+ console.log(`Downloading ${store} extension: ${id}`);
444
+ console.log(`URL: ${downloadUrl}`);
445
+
446
+ const response = await fetch(downloadUrl, {
447
+ headers: {
448
+ "User-Agent":
449
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
450
+ },
451
+ redirect: "follow",
452
+ });
453
+
454
+ if (!response.ok) {
455
+ throw new Error(
456
+ `Download failed: HTTP ${response.status} ${response.statusText}`
457
+ );
458
+ }
459
+
460
+ const arrayBuffer = await response.arrayBuffer();
461
+ const buffer = Buffer.from(arrayBuffer);
462
+
463
+ // Verify it's a valid CRX file
464
+ const magic = buffer.toString("ascii", 0, 4);
465
+ if (magic !== "Cr24") {
466
+ throw new Error(
467
+ `Invalid CRX response: got "${magic}" instead of "Cr24". The extension may not exist or the store may have changed.`
468
+ );
469
+ }
470
+
471
+ // Ensure output directory exists
472
+ await mkdir(outputDir, { recursive: true });
473
+
474
+ const outputPath = join(outputDir, `${id}.crx`);
475
+ await writeFile(outputPath, buffer);
476
+
477
+ console.log(`Saved: ${outputPath} (${(buffer.length / 1024).toFixed(1)} KB)`);
478
+ return outputPath;
479
+ }
480
+ ```
481
+
482
+ - [ ] **Step 2: Commit**
483
+
484
+ ```bash
485
+ git add src/downloader.ts
486
+ git commit -m "feat: add downloader for Chrome Web Store and Edge Add-ons CRX files"
487
+ ```
488
+
489
+ ---
490
+
491
+ ### Task 5: Extractor Module
492
+
493
+ **Files:**
494
+ - Create: `src/extractor.ts`
495
+ - Create: `test/extractor.test.ts`
496
+
497
+ **Interfaces:**
498
+ - Consumes: `extractZipFromCrx` from `crx-parser.ts`
499
+ - Produces: `extractCrx(crxPath: string, outputDir?: string): Promise<string>` — returns path to extracted directory
500
+
501
+ - [ ] **Step 1: Write failing tests**
502
+
503
+ ```typescript
504
+ // test/extractor.test.ts
505
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
506
+ import { extractCrx } from "../src/extractor";
507
+ import { mkdirSync, rmSync, writeFileSync, existsSync } from "fs";
508
+ import { join } from "path";
509
+
510
+ const TEST_DIR = join(import.meta.dir, "__test_extract__");
511
+
512
+ beforeEach(() => {
513
+ mkdirSync(TEST_DIR, { recursive: true });
514
+ });
515
+
516
+ afterEach(() => {
517
+ rmSync(TEST_DIR, { recursive: true, force: true });
518
+ });
519
+
520
+ test("extractCrx extracts a CRX file to directory", async () => {
521
+ // Create a minimal CRX3 wrapping a small ZIP
522
+ // For this test, we create a real ZIP first, then wrap in CRX header
523
+ // We'll use a fixture approach: create a ZIP with a manifest.json
524
+ const zipPath = join(TEST_DIR, "test.zip");
525
+
526
+ // Use bun to create a zip via command
527
+ const extDir = join(TEST_DIR, "ext_src");
528
+ mkdirSync(extDir, { recursive: true });
529
+ writeFileSync(join(extDir, "manifest.json"), JSON.stringify({ name: "test", version: "1.0" }));
530
+
531
+ const proc = Bun.spawn(["powershell", "-Command", `Compress-Archive -Path '${extDir}\\*' -DestinationPath '${zipPath}' -Force`]);
532
+ await proc.exited;
533
+
534
+ // Read the zip and wrap in CRX3 header
535
+ const zipBuf = Bun.file(zipPath);
536
+ const zipBytes = Buffer.from(await zipBuf.arrayBuffer());
537
+
538
+ // Build CRX3 header: Cr24 + version(3) + headerLen(4 bytes of protobuf)
539
+ const crxHeader = Buffer.alloc(12);
540
+ crxHeader.write("Cr24", 0);
541
+ crxHeader.writeUInt32LE(3, 4);
542
+ crxHeader.writeUInt32LE(4, 8); // header length = 4 dummy bytes
543
+
544
+ const dummyProto = Buffer.alloc(4);
545
+ const crxBuffer = Buffer.concat([crxHeader, dummyProto, zipBytes]);
546
+
547
+ const crxPath = join(TEST_DIR, "test.crx");
548
+ writeFileSync(crxPath, crxBuffer);
549
+
550
+ const outputDir = join(TEST_DIR, "extracted");
551
+ const result = await extractCrx(crxPath, outputDir);
552
+
553
+ expect(existsSync(join(result, "manifest.json"))).toBe(true);
554
+ });
555
+ ```
556
+
557
+ - [ ] **Step 2: Run tests to verify they fail**
558
+
559
+ ```bash
560
+ bun test test/extractor.test.ts
561
+ ```
562
+ Expected: FAIL — `extractCrx` not found
563
+
564
+ - [ ] **Step 3: Implement extractor.ts**
565
+
566
+ ```typescript
567
+ // src/extractor.ts
568
+ import { readFile, mkdir, writeFile, readdir } from "fs/promises";
569
+ import { join, basename } from "path";
570
+ import { extractZipFromCrx } from "./crx-parser";
571
+
572
+ /**
573
+ * Extract a CRX file to a directory.
574
+ * Reads the CRX header, extracts the embedded ZIP, and unzips to outputDir.
575
+ * Returns the path to the extracted directory.
576
+ */
577
+ export async function extractCrx(
578
+ crxPath: string,
579
+ outputDir?: string
580
+ ): Promise<string> {
581
+ const crxBuffer = await readFile(crxPath);
582
+ const zipBuffer = extractZipFromCrx(crxBuffer);
583
+
584
+ // Determine output directory name from CRX filename
585
+ const baseName = basename(crxPath, ".crx");
586
+ const destDir = outputDir ?? join(".", baseName);
587
+ await mkdir(destDir, { recursive: true });
588
+
589
+ // Write ZIP to temp file, then extract
590
+ const tempZip = join(destDir, "__temp__.zip");
591
+ await writeFile(tempZip, zipBuffer);
592
+
593
+ // Use Bun's built-in unzip via tar or powershell
594
+ const proc = Bun.spawn([
595
+ "powershell",
596
+ "-Command",
597
+ `Expand-Archive -Path '${tempZip}' -DestinationPath '${destDir}' -Force`,
598
+ ]);
599
+ const exitCode = await proc.exited;
600
+
601
+ if (exitCode !== 0) {
602
+ const stderr = await new Response(proc.stderr).text();
603
+ throw new Error(`Failed to extract ZIP: ${stderr}`);
604
+ }
605
+
606
+ // Clean up temp ZIP
607
+ const { unlink } = await import("fs/promises");
608
+ await unlink(tempZip);
609
+
610
+ console.log(`Extracted to: ${destDir}`);
611
+ return destDir;
612
+ }
613
+ ```
614
+
615
+ - [ ] **Step 4: Run tests to verify they pass**
616
+
617
+ ```bash
618
+ bun test test/extractor.test.ts
619
+ ```
620
+ Expected: PASS
621
+
622
+ - [ ] **Step 5: Commit**
623
+
624
+ ```bash
625
+ git add src/extractor.ts test/extractor.test.ts
626
+ git commit -m "feat: add CRX extractor that unzips CRX files to directories"
627
+ ```
628
+
629
+ ---
630
+
631
+ ### Task 6: Packer Module
632
+
633
+ **Files:**
634
+ - Create: `src/packer.ts`
635
+ - Create: `test/packer.test.ts`
636
+
637
+ **Interfaces:**
638
+ - Consumes: none
639
+ - Produces: `packCrx(dirPath: string, outputPath?: string): Promise<string>` — returns path to packed .crx file
640
+
641
+ - [ ] **Step 1: Write failing tests**
642
+
643
+ ```typescript
644
+ // test/packer.test.ts
645
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
646
+ import { packCrx } from "../src/packer";
647
+ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs";
648
+ import { join } from "path";
649
+
650
+ const TEST_DIR = join(import.meta.dir, "__test_pack__");
651
+
652
+ beforeEach(() => {
653
+ mkdirSync(TEST_DIR, { recursive: true });
654
+ });
655
+
656
+ afterEach(() => {
657
+ rmSync(TEST_DIR, { recursive: true, force: true });
658
+ });
659
+
660
+ test("packCrx creates a CRX file from directory", async () => {
661
+ const extDir = join(TEST_DIR, "my-ext");
662
+ mkdirSync(extDir, { recursive: true });
663
+ writeFileSync(
664
+ join(extDir, "manifest.json"),
665
+ JSON.stringify({ manifest_version: 3, name: "Test Ext", version: "1.0" })
666
+ );
667
+
668
+ const crxPath = join(TEST_DIR, "output.crx");
669
+ const result = await packCrx(extDir, crxPath);
670
+
671
+ expect(existsSync(result)).toBe(true);
672
+
673
+ // Verify it starts with Cr24 magic
674
+ const buf = readFileSync(result);
675
+ expect(buf.toString("ascii", 0, 4)).toBe("Cr24");
676
+ expect(buf.readUInt32LE(4)).toBe(3); // CRX version 3
677
+ });
678
+
679
+ test("packCrx generates .pem key if none exists", async () => {
680
+ const extDir = join(TEST_DIR, "my-ext2");
681
+ mkdirSync(extDir, { recursive: true });
682
+ writeFileSync(
683
+ join(extDir, "manifest.json"),
684
+ JSON.stringify({ manifest_version: 3, name: "Test", version: "1.0" })
685
+ );
686
+
687
+ await packCrx(extDir, join(TEST_DIR, "out.crx"));
688
+ expect(existsSync(join(extDir, "key.pem"))).toBe(true);
689
+ });
690
+ ```
691
+
692
+ - [ ] **Step 2: Run tests to verify they fail**
693
+
694
+ ```bash
695
+ bun test test/packer.test.ts
696
+ ```
697
+ Expected: FAIL — `packCrx` not found
698
+
699
+ - [ ] **Step 3: Implement packer.ts**
700
+
701
+ ```typescript
702
+ // src/packer.ts
703
+ import { readdir, readFile, writeFile, mkdir } from "fs/promises";
704
+ import { join, basename } from "path";
705
+ import forge from "node-forge";
706
+
707
+ /**
708
+ * Create a CRX3 file from an extension directory.
709
+ * Generates a .pem key if one doesn't exist in the directory.
710
+ * Returns the path to the created .crx file.
711
+ */
712
+ export async function packCrx(
713
+ dirPath: string,
714
+ outputPath?: string
715
+ ): Promise<string> {
716
+ // Read manifest to verify it's an extension
717
+ const manifestPath = join(dirPath, "manifest.json");
718
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
719
+ if (!manifest.name || !manifest.version) {
720
+ throw new Error("Invalid extension: manifest.json must have 'name' and 'version'");
721
+ }
722
+
723
+ // Load or generate private key
724
+ const keyPath = join(dirPath, "key.pem");
725
+ let privateKey: forge.pki.PrivateKey;
726
+ try {
727
+ const keyPem = await readFile(keyPath, "utf-8");
728
+ privateKey = forge.pki.privateKeyFromPem(keyPem);
729
+ } catch {
730
+ // Generate new 2048-bit RSA key
731
+ privateKey = forge.pki.rsa.generatePrivateKey(2048);
732
+ const pem = forge.pki.privateKeyToPem(privateKey);
733
+ await writeFile(keyPath, pem);
734
+ console.log(`Generated key: ${keyPath}`);
735
+ }
736
+
737
+ // Create ZIP from directory contents
738
+ const zipBuffer = await createZipFromDir(dirPath);
739
+
740
+ // Build CRX3 header
741
+ const publicKey = forge.pki.publicKeyToDer(forge.pki.publicKeyFromPem(
742
+ forge.pki.publicKeyToPem(privateKey.publicKey)
743
+ ));
744
+
745
+ // Create signed data: SHA256 of (CRX ID + ZIP)
746
+ const crxId = computeCrxId(publicKey);
747
+ const signedData = Buffer.concat([Buffer.from(crxId), zipBuffer]);
748
+ const sha256Digest = forge.md.sha256.create().update(signedData.getBytes()).digest().getBytes();
749
+
750
+ // Create RSA signature
751
+ const md = forge.md.sha256.create();
752
+ md.update(sha256Digest);
753
+ const signature = privateKey.sign(md);
754
+
755
+ // Build protobuf header manually (simplified CRX3 format)
756
+ const header = buildCrx3Header(publicKey, Buffer.from(signature, "binary"));
757
+
758
+ // Assemble CRX file
759
+ const magic = Buffer.from("Cr24");
760
+ const version = Buffer.alloc(4);
761
+ version.writeUInt32LE(3, 0);
762
+ const headerLen = Buffer.alloc(4);
763
+ headerLen.writeUInt32LE(header.length, 0);
764
+
765
+ const crx = Buffer.concat([magic, version, headerLen, header, zipBuffer]);
766
+
767
+ const outPath = outputPath ?? join(".", `${basename(dirPath)}.crx`);
768
+ await writeFile(outPath, crx);
769
+
770
+ console.log(`Packed: ${outPath} (${(crx.length / 1024).toFixed(1)} KB)`);
771
+ return outPath;
772
+ }
773
+
774
+ /**
775
+ * Compute a Chrome extension ID from a DER-encoded public key.
776
+ * This is the base-26 encoding of the key hash.
777
+ */
778
+ function computeCrxId(derPublicKey: Buffer): string {
779
+ const md = forge.md.sha256.create();
780
+ md.update(forge.util.binary.raw.encode(derPublicKey));
781
+ const hash = md.digest().getBytes();
782
+
783
+ // Chrome uses first 16 bytes of SHA256, then base-26 encodes
784
+ const bytes = Buffer.from(hash, "binary").subarray(0, 16);
785
+ const alphabet = "abcdefghijklmnop";
786
+
787
+ let id = "";
788
+ for (let i = 0; i < bytes.length; i++) {
789
+ id += alphabet[bytes[i] % 16];
790
+ }
791
+ return id;
792
+ }
793
+
794
+ /**
795
+ * Build a minimal CRX3 protobuf header.
796
+ * Format: field 2 (sha256_with_rsa) = AsymmetricKeyProof { public_key, signature }
797
+ */
798
+ function buildCrx3Header(publicKey: Buffer, signature: Buffer): Buffer {
799
+ // Protobuf encoding for CRX3 header
800
+ // This is a simplified encoder for the required fields
801
+
802
+ function encodeVarint(value: number): number[] {
803
+ const bytes: number[] = [];
804
+ while (value > 0x7f) {
805
+ bytes.push((value & 0x7f) | 0x80);
806
+ value >>>= 7;
807
+ }
808
+ bytes.push(value & 0x7f);
809
+ return bytes;
810
+ }
811
+
812
+ function encodeLengthDelimited(fieldNumber: number, data: Buffer): number[] {
813
+ const tag = (fieldNumber << 3) | 2;
814
+ return [...encodeVarint(tag), ...encodeVarint(data.length), ...data];
815
+ }
816
+
817
+ // AsymmetricKeyProof: field 1 = public_key, field 2 = signature
818
+ const publicKeyField = encodeLengthDelimited(1, publicKey);
819
+ const signatureField = encodeLengthDelimited(2, signature);
820
+ const asymmetricProof = Buffer.from([...publicKeyField, ...signatureField]);
821
+
822
+ // CrxFileHeader: field 2 = sha256_with_rsa (repeated)
823
+ const proofField = encodeLengthDelimited(2, asymmetricProof);
824
+
825
+ return Buffer.from(proofField);
826
+ }
827
+
828
+ /**
829
+ * Create a ZIP archive from a directory using PowerShell.
830
+ */
831
+ async function createZipFromDir(dirPath: string): Promise<Buffer> {
832
+ const tempZip = join(dirPath, "__temp_pack__.zip");
833
+
834
+ const proc = Bun.spawn([
835
+ "powershell",
836
+ "-Command",
837
+ `Compress-Archive -Path '${dirPath}\\*' -DestinationPath '${tempZip}' -Force`,
838
+ ]);
839
+ const exitCode = await proc.exited;
840
+
841
+ if (exitCode !== 0) {
842
+ const stderr = await new Response(proc.stderr).text();
843
+ throw new Error(`Failed to create ZIP: ${stderr}`);
844
+ }
845
+
846
+ const zipBuffer = Buffer.from(await Bun.file(tempZip).arrayBuffer());
847
+ await import("fs/promises").then((fs) => fs.unlink(tempZip));
848
+
849
+ return zipBuffer;
850
+ }
851
+ ```
852
+
853
+ - [ ] **Step 4: Run tests to verify they pass**
854
+
855
+ ```bash
856
+ bun test test/packer.test.ts
857
+ ```
858
+ Expected: PASS
859
+
860
+ - [ ] **Step 5: Commit**
861
+
862
+ ```bash
863
+ git add src/packer.ts test/packer.test.ts
864
+ git commit -m "feat: add CRX3 packer with auto key generation"
865
+ ```
866
+
867
+ ---
868
+
869
+ ### Task 7: CLI Entry Point
870
+
871
+ **Files:**
872
+ - Modify: `src/cli.ts`
873
+
874
+ **Interfaces:**
875
+ - Consumes: `downloadExtension`, `extractCrx`, `packCrx` from other modules
876
+ - Produces: working CLI binary
877
+
878
+ - [ ] **Step 1: Implement full CLI**
879
+
880
+ ```typescript
881
+ #!/usr/bin/env bun
882
+
883
+ import { Command } from "commander";
884
+ import { downloadExtension } from "./downloader";
885
+ import { extractCrx } from "./extractor";
886
+ import { packCrx } from "./packer";
887
+
888
+ const program = new Command();
889
+
890
+ program
891
+ .name("ext-cli")
892
+ .description("Chrome/Edge Extension Manager - download, extract, and pack extensions")
893
+ .version("1.0.0");
894
+
895
+ program
896
+ .command("get")
897
+ .description("Download an extension from Chrome Web Store or Edge Add-ons")
898
+ .argument("<url-or-id>", "Store URL or 32-char extension ID")
899
+ .option("-d, --dir <directory>", "Output directory", ".")
900
+ .option("-e, --extract", "Extract the CRX after downloading")
901
+ .action(async (urlOrId: string, opts: { dir: string; extract: boolean }) => {
902
+ try {
903
+ const crxPath = await downloadExtension(urlOrId, opts.dir);
904
+
905
+ if (opts.extract) {
906
+ const extDir = crxPath.replace(/\.crx$/, "");
907
+ await extractCrx(crxPath, extDir);
908
+ console.log(`\nDone! Extension extracted to: ${extDir}`);
909
+ } else {
910
+ console.log(`\nDone! CRX saved to: ${crxPath}`);
911
+ }
912
+ } catch (error) {
913
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
914
+ process.exit(1);
915
+ }
916
+ });
917
+
918
+ program
919
+ .command("extract")
920
+ .description("Extract a CRX file to a directory")
921
+ .argument("<crx-file>", "Path to .crx file")
922
+ .option("-d, --dir <directory>", "Output directory")
923
+ .action(async (crxFile: string, opts: { dir?: string }) => {
924
+ try {
925
+ const outputDir = opts.dir ?? crxFile.replace(/\.crx$/, "");
926
+ await extractCrx(crxFile, outputDir);
927
+ console.log(`\nDone! Extracted to: ${outputDir}`);
928
+ } catch (error) {
929
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
930
+ process.exit(1);
931
+ }
932
+ });
933
+
934
+ program
935
+ .command("pack")
936
+ .description("Pack a directory into a CRX file")
937
+ .argument("<directory>", "Extension directory containing manifest.json")
938
+ .option("-o, --output <path>", "Output CRX file path")
939
+ .action(async (directory: string, opts: { output?: string }) => {
940
+ try {
941
+ const crxPath = await packCrx(directory, opts.output);
942
+ console.log(`\nDone! Packed to: ${crxPath}`);
943
+ } catch (error) {
944
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
945
+ process.exit(1);
946
+ }
947
+ });
948
+
949
+ program.parse();
950
+ ```
951
+
952
+ - [ ] **Step 2: Test CLI manually**
953
+
954
+ ```bash
955
+ # Test help
956
+ bun run src/cli.ts --help
957
+
958
+ # Test get help
959
+ bun run src/cli.ts get --help
960
+
961
+ # Test extract help
962
+ bun run src/cli.ts extract --help
963
+
964
+ # Test pack help
965
+ bun run src/cli.ts pack --help
966
+ ```
967
+
968
+ - [ ] **Step 3: Commit**
969
+
970
+ ```bash
971
+ git add src/cli.ts
972
+ git commit -m "feat: implement ext-cli with get, extract, and pack commands"
973
+ ```
974
+
975
+ ---
976
+
977
+ ### Task 8: End-to-End Testing
978
+
979
+ **Files:**
980
+ - No new files
981
+
982
+ **Interfaces:**
983
+ - Consumes: all modules
984
+ - Produces: verified working CLI
985
+
986
+ - [ ] **Step 1: Test downloading a real extension**
987
+
988
+ ```bash
989
+ # Download uBlock Origin from Chrome Web Store
990
+ bun run src/cli.ts get "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm" -d "./test-output"
991
+
992
+ # Verify .crx file exists
993
+ dir test-output
994
+ ```
995
+
996
+ - [ ] **Step 2: Test download + extract**
997
+
998
+ ```bash
999
+ # Download and extract in one step
1000
+ bun run src/cli.ts get "https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm" -d "./test-output2" -e
1001
+
1002
+ # Verify extracted directory
1003
+ dir test-output2
1004
+ ```
1005
+
1006
+ - [ ] **Step 3: Test extract from local file**
1007
+
1008
+ ```bash
1009
+ # Extract the previously downloaded CRX
1010
+ bun run src/cli.ts extract "./test-output/cjpalhdlnbpafiamejdnhcphjbkeiagm.crx" -d "./test-extracted"
1011
+ ```
1012
+
1013
+ - [ ] **Step 4: Test pack from directory**
1014
+
1015
+ ```bash
1016
+ # Pack the extracted directory back to CRX
1017
+ bun run src/cli.ts pack "./test-extracted" -o "./test-packed.crx"
1018
+ ```
1019
+
1020
+ - [ ] **Step 5: Clean up test files**
1021
+
1022
+ ```bash
1023
+ Remove-Item -Recurse -Force ./test-output, ./test-output2, ./test-extracted, ./test-packed.crx -ErrorAction SilentlyContinue
1024
+ ```
1025
+
1026
+ - [ ] **Step 6: Commit**
1027
+
1028
+ ```bash
1029
+ git add -A
1030
+ git commit -m "test: verify end-to-end workflow for download, extract, and pack"
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ### Task 9: Build & Release
1036
+
1037
+ **Files:**
1038
+ - Modify: `package.json`
1039
+
1040
+ **Interfaces:**
1041
+ - Consumes: all source files
1042
+ - Produces: standalone binary `ext-cli`
1043
+
1044
+ - [ ] **Step 1: Build standalone binary**
1045
+
1046
+ ```bash
1047
+ bun build src/cli.ts --compile --outfile ext-cli.exe
1048
+ ```
1049
+
1050
+ - [ ] **Step 2: Test the binary**
1051
+
1052
+ ```bash
1053
+ .\ext-cli.exe --help
1054
+ .\ext-cli.exe get --help
1055
+ ```
1056
+
1057
+ - [ ] **Step 3: Update package.json with bin field**
1058
+
1059
+ Ensure `package.json` has:
1060
+ ```json
1061
+ {
1062
+ "bin": {
1063
+ "ext-cli": "./ext-cli.exe"
1064
+ }
1065
+ }
1066
+ ```
1067
+
1068
+ - [ ] **Step 4: Final commit**
1069
+
1070
+ ```bash
1071
+ git add -A
1072
+ git commit -m "feat: add standalone binary build for ext-cli"
1073
+ ```
1074
+
1075
+ ---
1076
+
1077
+ ## Summary
1078
+
1079
+ | Task | Description | Dependencies |
1080
+ |------|-------------|--------------|
1081
+ | 1 | Project scaffolding | None |
1082
+ | 2 | URL parser | None |
1083
+ | 3 | CRX parser | None |
1084
+ | 4 | Downloader | Task 2 |
1085
+ | 5 | Extractor | Task 3 |
1086
+ | 6 | Packer | None |
1087
+ | 7 | CLI entry point | Tasks 4, 5, 6 |
1088
+ | 8 | E2E testing | Task 7 |
1089
+ | 9 | Build & release | Task 8 |
1090
+
1091
+ **Parallelizable:** Tasks 2, 3, and 6 can be done in parallel. Task 4 depends on 2. Task 5 depends on 3.
1092
+
1093
+ ---
1094
+
1095
+ ## CLI Usage Examples
1096
+
1097
+ ```bash
1098
+ # Download extension (saves .crx file)
1099
+ ext-cli get "https://chromewebstore.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm"
1100
+
1101
+ # Download and extract to directory
1102
+ ext-cli get "https://chromewebstore.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm" -e
1103
+
1104
+ # Download to specific directory
1105
+ ext-cli get cjpalhdlnbpafiamejdnhcphjbkeiagm -d "./extensions" -e
1106
+
1107
+ # Download Edge extension
1108
+ ext-cli get "https://microsoftedge.microsoft.com/addons/detail/ublock-origin/odfafepnkmbhccpbejgmiehpchacaeak" -e
1109
+
1110
+ # Extract local CRX file
1111
+ ext-cli extract ./my-extension.crx -d ./unpacked
1112
+
1113
+ # Pack directory to CRX (auto-generates key.pem)
1114
+ ext-cli pack ./my-extension -o ./my-extension.crx
1115
+ ```