@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.
- package/.github/FUNDING.yml +9 -0
- package/.superpowers/sdd/progress.md +15 -0
- package/.superpowers/sdd/task-1-brief.md +89 -0
- package/.superpowers/sdd/task-1-report.md +52 -0
- package/.superpowers/sdd/task-2-brief.md +113 -0
- package/.superpowers/sdd/task-2-report.md +27 -0
- package/.superpowers/sdd/task-3-brief.md +123 -0
- package/.superpowers/sdd/task-3-report.md +26 -0
- package/.superpowers/sdd/task-4-brief.md +85 -0
- package/.superpowers/sdd/task-4-report.md +33 -0
- package/.superpowers/sdd/task-5-brief.md +122 -0
- package/.superpowers/sdd/task-5-report.md +22 -0
- package/.superpowers/sdd/task-6-brief.md +96 -0
- package/.superpowers/sdd/task-6-report.md +24 -0
- package/.superpowers/sdd/task-7-brief.md +100 -0
- package/.superpowers/sdd/task-7-report.md +30 -0
- package/AGENTS.md +264 -0
- package/README.md +177 -0
- package/biome.json +25 -0
- package/docs/superpowers/plans/2026-07-05-ext-cli.md +1115 -0
- package/package.json +34 -0
- package/src/cli.ts +209 -0
- package/src/crx-parser.ts +35 -0
- package/src/downloader.ts +61 -0
- package/src/extractor.ts +34 -0
- package/src/index.ts +16 -0
- package/src/launcher.ts +107 -0
- package/src/packer.ts +127 -0
- package/src/search.ts +115 -0
- package/src/url-parser.ts +33 -0
- package/test/crx-parser.test.ts +48 -0
- package/test/extractor.test.ts +49 -0
- package/test/packer.test.ts +44 -0
- package/test/url-parser.test.ts +44 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
```
|