@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
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@involvex/ext-cli",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"author": "involvex",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ext-cli": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "bun build src/cli.ts --compile --outfile dist/ext-cli.exe",
|
|
11
|
+
"dev": "bun run src/cli.ts",
|
|
12
|
+
"test": "bun test",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "biome check src/ test/",
|
|
15
|
+
"lint:fix": "biome check src/ test/ --fix",
|
|
16
|
+
"format": "biome format --write src/ test/",
|
|
17
|
+
"check": "bun run format && bun run lint:fix && bun run typecheck",
|
|
18
|
+
"prebuild": "bun run check"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@biomejs/biome": "^2.5.2",
|
|
22
|
+
"@types/bun": "latest",
|
|
23
|
+
"@types/node": "^26.1.0",
|
|
24
|
+
"@types/node-forge": "^1.3.14"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"typescript": "^6.0.3"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"chromium-edge-launcher": "^2.0.1",
|
|
31
|
+
"commander": "^15.0.0",
|
|
32
|
+
"node-forge": "^1.4.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { downloadExtension } from "./downloader";
|
|
5
|
+
import { extractCrx } from "./extractor";
|
|
6
|
+
import { getEdgeInstallationPath, launch } from "./launcher";
|
|
7
|
+
import { packCrx } from "./packer";
|
|
8
|
+
import { searchExtensions } from "./search";
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("ext-cli")
|
|
14
|
+
.description("Chrome/Edge Extension Manager - download, extract, and pack extensions")
|
|
15
|
+
.version("1.0.0");
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command("get")
|
|
19
|
+
.description("Download an extension from Chrome Web Store or Edge Add-ons")
|
|
20
|
+
.argument("<url-or-id>", "Store URL or 32-char extension ID")
|
|
21
|
+
.option("-d, --dir <directory>", "Output directory", ".")
|
|
22
|
+
.option("-e, --extract", "Extract the CRX after downloading")
|
|
23
|
+
.action(async (urlOrId: string, opts: { dir: string; extract: boolean }) => {
|
|
24
|
+
try {
|
|
25
|
+
const crxPath = await downloadExtension(urlOrId, opts.dir);
|
|
26
|
+
|
|
27
|
+
if (opts.extract) {
|
|
28
|
+
const extDir = crxPath.replace(/\.crx$/, "");
|
|
29
|
+
await extractCrx(crxPath, extDir);
|
|
30
|
+
console.log(`\nDone! Extension extracted to: ${extDir}`);
|
|
31
|
+
} else {
|
|
32
|
+
console.log(`\nDone! CRX saved to: ${crxPath}`);
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command("extract")
|
|
42
|
+
.description("Extract a CRX file to a directory")
|
|
43
|
+
.argument("<crx-file>", "Path to .crx file")
|
|
44
|
+
.option("-d, --dir <directory>", "Output directory")
|
|
45
|
+
.action(async (crxFile: string, opts: { dir?: string }) => {
|
|
46
|
+
try {
|
|
47
|
+
const outputDir = opts.dir ?? crxFile.replace(/\.crx$/, "");
|
|
48
|
+
await extractCrx(crxFile, outputDir);
|
|
49
|
+
console.log(`\nDone! Extracted to: ${outputDir}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command("pack")
|
|
58
|
+
.description("Pack a directory into a CRX file")
|
|
59
|
+
.argument("<directory>", "Extension directory containing manifest.json")
|
|
60
|
+
.option("-o, --output <path>", "Output CRX file path")
|
|
61
|
+
.action(async (directory: string, opts: { output?: string }) => {
|
|
62
|
+
try {
|
|
63
|
+
const crxPath = await packCrx(directory, opts.output);
|
|
64
|
+
console.log(`\nDone! Packed to: ${crxPath}`);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program
|
|
72
|
+
.command("search")
|
|
73
|
+
.description("Search Chrome Web Store and Edge Add-ons for extensions")
|
|
74
|
+
.argument("<query>", "Search query")
|
|
75
|
+
.action(async (query: string) => {
|
|
76
|
+
try {
|
|
77
|
+
console.log(`Searching for "${query}"...`);
|
|
78
|
+
const results = await searchExtensions(query);
|
|
79
|
+
|
|
80
|
+
if (results.length === 0) {
|
|
81
|
+
console.log("No extensions found.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(`\nFound ${results.length} extensions:\n`);
|
|
86
|
+
|
|
87
|
+
for (const result of results) {
|
|
88
|
+
console.log(`- ${result.name}`);
|
|
89
|
+
console.log(` ID: ${result.id}`);
|
|
90
|
+
console.log(` Store: ${result.store}`);
|
|
91
|
+
console.log("");
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
program
|
|
100
|
+
.command("launch")
|
|
101
|
+
.description("Launch Edge with an extension loaded")
|
|
102
|
+
.option("-e, --extension <path>", "Path to extension directory or .crx file")
|
|
103
|
+
.option("--headless", "Run in headless mode")
|
|
104
|
+
.option("-u, --url <url>", "Starting URL", "about:blank")
|
|
105
|
+
.option("-p, --port <port>", "Remote debugging port")
|
|
106
|
+
.option("--keep-open", "Keep Edge open after Ctrl-C (don't auto-kill)")
|
|
107
|
+
.option("--user-data-dir <path>", "Custom user data directory")
|
|
108
|
+
.action(
|
|
109
|
+
async (opts: {
|
|
110
|
+
extension?: string;
|
|
111
|
+
headless?: boolean;
|
|
112
|
+
url?: string;
|
|
113
|
+
port?: string;
|
|
114
|
+
keepOpen?: boolean;
|
|
115
|
+
userDataDir?: string;
|
|
116
|
+
}) => {
|
|
117
|
+
try {
|
|
118
|
+
const edge = await launch({
|
|
119
|
+
extension: opts.extension,
|
|
120
|
+
headless: opts.headless,
|
|
121
|
+
url: opts.url,
|
|
122
|
+
port: opts.port ? parseInt(opts.port, 10) : undefined,
|
|
123
|
+
keepOpen: opts.keepOpen,
|
|
124
|
+
userDataDir: opts.userDataDir,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`\nEdge launched!`);
|
|
128
|
+
console.log(` Debugging port: ${edge.port}`);
|
|
129
|
+
console.log(` Process ID: ${edge.pid}`);
|
|
130
|
+
|
|
131
|
+
if (opts.keepOpen) {
|
|
132
|
+
console.log(` Press Ctrl-C to stop Edge (--keep-open enabled)`);
|
|
133
|
+
} else {
|
|
134
|
+
console.log(` Press Ctrl-C to stop Edge`);
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.command("test")
|
|
145
|
+
.description("Download, extract, and launch an extension for testing")
|
|
146
|
+
.argument("<url-or-id>", "Store URL or 32-char extension ID")
|
|
147
|
+
.option("-d, --dir <directory>", "Output directory for download", ".")
|
|
148
|
+
.option("--headless", "Run in headless mode")
|
|
149
|
+
.option("-u, --url <url>", "Starting URL after launch", "about:blank")
|
|
150
|
+
.option("-p, --port <port>", "Remote debugging port")
|
|
151
|
+
.option("--keep-open", "Keep Edge open after Ctrl-C")
|
|
152
|
+
.action(
|
|
153
|
+
async (
|
|
154
|
+
urlOrId: string,
|
|
155
|
+
opts: {
|
|
156
|
+
dir?: string;
|
|
157
|
+
headless?: boolean;
|
|
158
|
+
url?: string;
|
|
159
|
+
port?: string;
|
|
160
|
+
keepOpen?: boolean;
|
|
161
|
+
},
|
|
162
|
+
) => {
|
|
163
|
+
try {
|
|
164
|
+
console.log(`Downloading extension...`);
|
|
165
|
+
const crxPath = await downloadExtension(urlOrId, opts.dir ?? ".");
|
|
166
|
+
|
|
167
|
+
console.log(`Extracting...`);
|
|
168
|
+
const extDir = crxPath.replace(/\.crx$/, "");
|
|
169
|
+
await extractCrx(crxPath, extDir);
|
|
170
|
+
|
|
171
|
+
console.log(`Launching Edge...`);
|
|
172
|
+
const edge = await launch({
|
|
173
|
+
extension: extDir,
|
|
174
|
+
headless: opts.headless,
|
|
175
|
+
url: opts.url,
|
|
176
|
+
port: opts.port ? parseInt(opts.port, 10) : undefined,
|
|
177
|
+
keepOpen: opts.keepOpen,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log(`\nDone! Edge launched with extension.`);
|
|
181
|
+
console.log(` Debugging port: ${edge.port}`);
|
|
182
|
+
console.log(` Process ID: ${edge.pid}`);
|
|
183
|
+
|
|
184
|
+
if (opts.keepOpen) {
|
|
185
|
+
console.log(` Press Ctrl-C to stop Edge (--keep-open enabled)`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(` Press Ctrl-C to stop Edge`);
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
program
|
|
197
|
+
.command("edge-path")
|
|
198
|
+
.description("Show the path to the detected Edge installation")
|
|
199
|
+
.action(() => {
|
|
200
|
+
try {
|
|
201
|
+
const path = getEdgeInstallationPath();
|
|
202
|
+
console.log(path);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
program.parse();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/crx-parser.ts
|
|
2
|
+
|
|
3
|
+
export interface CrxInfo {
|
|
4
|
+
version: number;
|
|
5
|
+
headerLength: number;
|
|
6
|
+
zipOffset: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CRX_MAGIC = "Cr24";
|
|
10
|
+
|
|
11
|
+
export function parseCrxHeader(buffer: Buffer): CrxInfo {
|
|
12
|
+
if (buffer.length < 12) {
|
|
13
|
+
throw new Error("Not a CRX file: buffer too small");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const magic = buffer.toString("ascii", 0, 4);
|
|
17
|
+
if (magic !== CRX_MAGIC) {
|
|
18
|
+
throw new Error(`Not a CRX file: invalid magic "${magic}" (expected "${CRX_MAGIC}")`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const version = buffer.readUInt32LE(4);
|
|
22
|
+
if (version !== 2 && version !== 3) {
|
|
23
|
+
throw new Error(`Unsupported CRX version: ${version}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const headerLength = buffer.readUInt32LE(8);
|
|
27
|
+
const zipOffset = 12 + headerLength;
|
|
28
|
+
|
|
29
|
+
return { version, headerLength, zipOffset };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function extractZipFromCrx(buffer: Buffer): Buffer {
|
|
33
|
+
const info = parseCrxHeader(buffer);
|
|
34
|
+
return buffer.subarray(info.zipOffset);
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/downloader.ts
|
|
2
|
+
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseStoreUrl } from "./url-parser";
|
|
6
|
+
|
|
7
|
+
const CHROME_CRX_URL =
|
|
8
|
+
"https://clients2.google.com/service/update2/crx?response=redirect&prodversion=131.0&acceptformat=crx2,crx3&x=id%3D{ID}%26uc";
|
|
9
|
+
|
|
10
|
+
const EDGE_CRX_URL =
|
|
11
|
+
"https://edge.microsoft.com/extensionwebstorebase/v1/crx?response=redirect&x=id%3D{ID}%26installsource%3Dondemand%26uc";
|
|
12
|
+
|
|
13
|
+
function buildDownloadUrl(id: string, store: "chrome" | "edge"): string {
|
|
14
|
+
const template = store === "chrome" ? CHROME_CRX_URL : EDGE_CRX_URL;
|
|
15
|
+
return template.replace("{ID}", id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Download a CRX file from the Chrome Web Store or Edge Add-ons.
|
|
20
|
+
* Accepts a full store URL or a bare 32-char extension ID.
|
|
21
|
+
* Returns the path to the saved .crx file.
|
|
22
|
+
*/
|
|
23
|
+
export async function downloadExtension(urlOrId: string, outputDir: string = "."): Promise<string> {
|
|
24
|
+
const { id, store } = parseStoreUrl(urlOrId);
|
|
25
|
+
const downloadUrl = buildDownloadUrl(id, store);
|
|
26
|
+
|
|
27
|
+
console.log(`Downloading ${store} extension: ${id}`);
|
|
28
|
+
console.log(`URL: ${downloadUrl}`);
|
|
29
|
+
|
|
30
|
+
const response = await fetch(downloadUrl, {
|
|
31
|
+
headers: {
|
|
32
|
+
"User-Agent":
|
|
33
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
34
|
+
},
|
|
35
|
+
redirect: "follow",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`Download failed: HTTP ${response.status} ${response.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
43
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
44
|
+
|
|
45
|
+
// Verify it's a valid CRX file
|
|
46
|
+
const magic = buffer.toString("ascii", 0, 4);
|
|
47
|
+
if (magic !== "Cr24") {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Invalid CRX response: got "${magic}" instead of "Cr24". The extension may not exist or the store may have changed.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ensure output directory exists
|
|
54
|
+
await mkdir(outputDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
const outputPath = join(outputDir, `${id}.crx`);
|
|
57
|
+
await writeFile(outputPath, buffer);
|
|
58
|
+
|
|
59
|
+
console.log(`Saved: ${outputPath} (${(buffer.length / 1024).toFixed(1)} KB)`);
|
|
60
|
+
return outputPath;
|
|
61
|
+
}
|
package/src/extractor.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/extractor.ts
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { extractZipFromCrx } from "./crx-parser";
|
|
5
|
+
|
|
6
|
+
export async function extractCrx(crxPath: string, outputDir?: string): Promise<string> {
|
|
7
|
+
const crxBuffer = await readFile(crxPath);
|
|
8
|
+
const zipBuffer = extractZipFromCrx(crxBuffer);
|
|
9
|
+
|
|
10
|
+
const baseName = basename(crxPath, ".crx");
|
|
11
|
+
const destDir = outputDir ?? join(".", baseName);
|
|
12
|
+
await mkdir(destDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
const tempZip = join(destDir, "__temp__.zip");
|
|
15
|
+
await writeFile(tempZip, zipBuffer);
|
|
16
|
+
|
|
17
|
+
const proc = Bun.spawn([
|
|
18
|
+
"powershell",
|
|
19
|
+
"-Command",
|
|
20
|
+
`Expand-Archive -Path '${tempZip}' -DestinationPath '${destDir}' -Force`,
|
|
21
|
+
]);
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
|
|
24
|
+
if (exitCode !== 0) {
|
|
25
|
+
const stderr = await new Response(proc.stderr).text();
|
|
26
|
+
throw new Error(`Failed to extract ZIP: ${stderr}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { unlink } = await import("node:fs/promises");
|
|
30
|
+
await unlink(tempZip);
|
|
31
|
+
|
|
32
|
+
console.log(`Extracted to: ${destDir}`);
|
|
33
|
+
return destDir;
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { downloadExtension } from "./downloader";
|
|
2
|
+
export { extractCrx } from "./extractor";
|
|
3
|
+
export {
|
|
4
|
+
getDefaultFlags,
|
|
5
|
+
getEdgeInstallationPath,
|
|
6
|
+
killAllEdges,
|
|
7
|
+
type LaunchedEdge,
|
|
8
|
+
type LaunchOptions,
|
|
9
|
+
launch,
|
|
10
|
+
} from "./launcher";
|
|
11
|
+
export { packCrx } from "./packer";
|
|
12
|
+
export {
|
|
13
|
+
searchChromeWebStore,
|
|
14
|
+
searchEdgeAddons,
|
|
15
|
+
searchExtensions,
|
|
16
|
+
} from "./search";
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getEdgePath, killAll, Launcher, launch as launchEdge } from "chromium-edge-launcher";
|
|
6
|
+
import { extractCrx } from "./extractor";
|
|
7
|
+
|
|
8
|
+
export interface LaunchOptions {
|
|
9
|
+
extension?: string;
|
|
10
|
+
headless?: boolean;
|
|
11
|
+
url?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
edgeFlags?: string[];
|
|
14
|
+
keepOpen?: boolean;
|
|
15
|
+
userDataDir?: string | boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LaunchedEdge {
|
|
19
|
+
port: number;
|
|
20
|
+
pid: number;
|
|
21
|
+
kill: () => void;
|
|
22
|
+
process: ChildProcess;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isCrxFile(path: string): boolean {
|
|
26
|
+
return path.toLowerCase().endsWith(".crx");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function prepareExtensionPath(extensionPath: string): Promise<string> {
|
|
30
|
+
if (isCrxFile(extensionPath)) {
|
|
31
|
+
const tempDir = mkdtempSync(join(tmpdir(), "ext-cli-"));
|
|
32
|
+
await extractCrx(extensionPath, tempDir);
|
|
33
|
+
return tempDir;
|
|
34
|
+
}
|
|
35
|
+
return extensionPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function launch(options: LaunchOptions = {}): Promise<LaunchedEdge> {
|
|
39
|
+
const {
|
|
40
|
+
extension,
|
|
41
|
+
headless = false,
|
|
42
|
+
url = "about:blank",
|
|
43
|
+
port,
|
|
44
|
+
edgeFlags = [],
|
|
45
|
+
keepOpen = false,
|
|
46
|
+
userDataDir,
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
const flags = [...edgeFlags];
|
|
50
|
+
|
|
51
|
+
if (headless) {
|
|
52
|
+
flags.push("--headless=new", "--disable-gpu");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!keepOpen) {
|
|
56
|
+
flags.push("--disable-extensions-except");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let extPath: string | undefined;
|
|
60
|
+
let tempExtDir: string | undefined;
|
|
61
|
+
|
|
62
|
+
if (extension) {
|
|
63
|
+
if (!existsSync(extension)) {
|
|
64
|
+
throw new Error(`Extension path does not exist: ${extension}`);
|
|
65
|
+
}
|
|
66
|
+
extPath = await prepareExtensionPath(extension);
|
|
67
|
+
if (isCrxFile(extension)) {
|
|
68
|
+
tempExtDir = extPath;
|
|
69
|
+
}
|
|
70
|
+
flags.push(`--load-extension=${extPath}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const launched = await launchEdge({
|
|
74
|
+
startingUrl: url,
|
|
75
|
+
port,
|
|
76
|
+
edgeFlags: flags,
|
|
77
|
+
handleSIGINT: !keepOpen,
|
|
78
|
+
userDataDir,
|
|
79
|
+
ignoreDefaultFlags: false,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const kill = () => {
|
|
83
|
+
launched.kill();
|
|
84
|
+
if (tempExtDir && existsSync(tempExtDir)) {
|
|
85
|
+
rmSync(tempExtDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
port: launched.port,
|
|
91
|
+
pid: launched.pid,
|
|
92
|
+
kill,
|
|
93
|
+
process: launched.process,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function killAllEdges(): Error[] {
|
|
98
|
+
return killAll();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getEdgeInstallationPath(): string {
|
|
102
|
+
return getEdgePath();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getDefaultFlags(): string[] {
|
|
106
|
+
return Launcher.defaultFlags();
|
|
107
|
+
}
|
package/src/packer.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import forge from "node-forge";
|
|
5
|
+
|
|
6
|
+
const ALPHABET = "abcdefghijklmnop";
|
|
7
|
+
const CHUNK_SIZE = 8192;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safely encode a Buffer to a forge binary string without stack overflow.
|
|
11
|
+
* forge.util.binary.raw.encode uses String.fromCharCode.apply which
|
|
12
|
+
* overflows the stack for large buffers.
|
|
13
|
+
*/
|
|
14
|
+
function safeBinaryEncode(buf: Buffer): string {
|
|
15
|
+
let result = "";
|
|
16
|
+
for (let i = 0; i < buf.length; i += CHUNK_SIZE) {
|
|
17
|
+
result += String.fromCharCode.apply(
|
|
18
|
+
null,
|
|
19
|
+
buf.subarray(i, i + CHUNK_SIZE) as unknown as number[],
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function computeExtensionId(publicKeyDer: Buffer): string {
|
|
26
|
+
const md = forge.md.sha256.create();
|
|
27
|
+
md.update(forge.util.binary.raw.encode(publicKeyDer));
|
|
28
|
+
const hash = forge.util.binary.raw.decode(md.digest().getBytes());
|
|
29
|
+
const idBytes = Array.from(hash.slice(0, 16));
|
|
30
|
+
return idBytes.map((b: number) => ALPHABET[b % 16]).join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function encodeVarint(value: number): number[] {
|
|
34
|
+
const bytes: number[] = [];
|
|
35
|
+
while (value > 0x7f) {
|
|
36
|
+
bytes.push((value & 0x7f) | 0x80);
|
|
37
|
+
value >>>= 7;
|
|
38
|
+
}
|
|
39
|
+
bytes.push(value & 0x7f);
|
|
40
|
+
return bytes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encodeField(fieldNumber: number, wireType: number, data: number[]): number[] {
|
|
44
|
+
const tag = encodeVarint((fieldNumber << 3) | wireType);
|
|
45
|
+
return [...tag, ...encodeVarint(data.length), ...data];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildCrx3Header(publicKeyDer: Buffer, signature: Buffer): Buffer {
|
|
49
|
+
const _pubKeyProof = encodeField(1, 2, [
|
|
50
|
+
...encodeField(1, 2, Array.from(publicKeyDer)),
|
|
51
|
+
...encodeField(2, 2, Array.from(signature)),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const asp = encodeField(2, 2, [
|
|
55
|
+
...encodeField(1, 2, Array.from(publicKeyDer)),
|
|
56
|
+
...encodeField(2, 2, Array.from(signature)),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const header = encodeField(1, 2, asp);
|
|
60
|
+
return Buffer.from(header);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function packCrx(dirPath: string, outputPath?: string): Promise<string> {
|
|
64
|
+
const manifestPath = join(dirPath, "manifest.json");
|
|
65
|
+
if (!existsSync(manifestPath)) {
|
|
66
|
+
throw new Error("No manifest.json found in directory");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
70
|
+
|
|
71
|
+
const keyPath = join(dirPath, "key.pem");
|
|
72
|
+
let privateKey: forge.pki.PrivateKey;
|
|
73
|
+
|
|
74
|
+
if (existsSync(keyPath)) {
|
|
75
|
+
const pem = readFileSync(keyPath, "utf-8");
|
|
76
|
+
privateKey = forge.pki.privateKeyFromPem(pem);
|
|
77
|
+
} else {
|
|
78
|
+
privateKey = forge.pki.rsa.generateKeyPair({ bits: 2048 }).privateKey;
|
|
79
|
+
const pem = forge.pki.privateKeyToPem(privateKey);
|
|
80
|
+
writeFileSync(keyPath, pem, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pubDerBuf = Buffer.from(
|
|
84
|
+
forge.asn1
|
|
85
|
+
.toDer(forge.pki.publicKeyToAsn1(forge.pki.setRsaPublicKey(privateKey.n, privateKey.e)))
|
|
86
|
+
.getBytes(),
|
|
87
|
+
"binary",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const crxId = computeExtensionId(pubDerBuf);
|
|
91
|
+
|
|
92
|
+
const tmpZip = join(dirPath, "..", "__tmp_pack__.zip");
|
|
93
|
+
try {
|
|
94
|
+
execSync(
|
|
95
|
+
`powershell -NoProfile -Command "Compress-Archive -Path '${join(dirPath, "*")}' -DestinationPath '${tmpZip}' -Force"`,
|
|
96
|
+
{ stdio: "pipe" },
|
|
97
|
+
);
|
|
98
|
+
} catch (e: unknown) {
|
|
99
|
+
throw new Error(`Failed to create ZIP: ${e instanceof Error ? e.message : String(e)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const zipData = readFileSync(tmpZip);
|
|
103
|
+
|
|
104
|
+
const signMd = forge.md.sha256.create();
|
|
105
|
+
signMd.update(crxId);
|
|
106
|
+
signMd.update(safeBinaryEncode(zipData));
|
|
107
|
+
|
|
108
|
+
const sigBytes = privateKey.sign(signMd);
|
|
109
|
+
|
|
110
|
+
const headerBuf = buildCrx3Header(pubDerBuf, Buffer.from(sigBytes, "binary"));
|
|
111
|
+
|
|
112
|
+
const out = outputPath ?? join(dirPath, "output.crx");
|
|
113
|
+
const magic = Buffer.from("Cr24");
|
|
114
|
+
const version = Buffer.alloc(4);
|
|
115
|
+
version.writeUInt32LE(3, 0);
|
|
116
|
+
const headerLen = Buffer.alloc(4);
|
|
117
|
+
headerLen.writeUInt32LE(headerBuf.length, 0);
|
|
118
|
+
|
|
119
|
+
const result = Buffer.concat([magic, version, headerLen, headerBuf, zipData]);
|
|
120
|
+
writeFileSync(out, result);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(tmpZip);
|
|
124
|
+
} catch {}
|
|
125
|
+
|
|
126
|
+
return out;
|
|
127
|
+
}
|