@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/src/search.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/search.ts
|
|
2
|
+
|
|
3
|
+
export interface SearchResult {
|
|
4
|
+
name: string;
|
|
5
|
+
id: string;
|
|
6
|
+
store: "chrome" | "edge";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Search the Chrome Web Store for extensions matching a query.
|
|
11
|
+
*/
|
|
12
|
+
export async function searchChromeWebStore(query: string): Promise<SearchResult[]> {
|
|
13
|
+
const url = `https://chromewebstore.google.com/search/${encodeURIComponent(query)}`;
|
|
14
|
+
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
headers: {
|
|
17
|
+
"User-Agent":
|
|
18
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Chrome Web Store search failed: HTTP ${response.status} ${response.statusText}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const html = await response.text();
|
|
29
|
+
|
|
30
|
+
// Extract extension IDs and names from the search results
|
|
31
|
+
const regex = /detail\/([^/"']+)\/([a-z]{32})/g;
|
|
32
|
+
const results: SearchResult[] = [];
|
|
33
|
+
// eslint-disable-next-line no-constant-condition
|
|
34
|
+
while (true) {
|
|
35
|
+
const match = regex.exec(html);
|
|
36
|
+
if (!match) break;
|
|
37
|
+
const slug = match[1];
|
|
38
|
+
const id = match[2];
|
|
39
|
+
|
|
40
|
+
// Try to find a better name near this match
|
|
41
|
+
const start = Math.max(0, match.index - 500);
|
|
42
|
+
const end = Math.min(html.length, match.index + 500);
|
|
43
|
+
const snippet = html.substring(start, end);
|
|
44
|
+
|
|
45
|
+
// Look for aria-label or title with the name
|
|
46
|
+
const nameMatch = snippet.match(/aria-label="([^"]+)"/);
|
|
47
|
+
const name = nameMatch ? nameMatch[1] : slug;
|
|
48
|
+
|
|
49
|
+
// Avoid duplicates
|
|
50
|
+
if (!results.find((r) => r.id === id)) {
|
|
51
|
+
results.push({ name, id, store: "chrome" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Search the Edge Add-ons store for extensions matching a query.
|
|
60
|
+
*/
|
|
61
|
+
export async function searchEdgeAddons(query: string): Promise<SearchResult[]> {
|
|
62
|
+
const url = `https://microsoftedge.microsoft.com/addons/search/${encodeURIComponent(query)}`;
|
|
63
|
+
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
headers: {
|
|
66
|
+
"User-Agent":
|
|
67
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(`Edge Add-ons search failed: HTTP ${response.status} ${response.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const html = await response.text();
|
|
76
|
+
|
|
77
|
+
// Extract extension IDs and names from the search results
|
|
78
|
+
const regex = /addons\/detail\/([^/"']+)\/([a-z0-9]+)/g;
|
|
79
|
+
const results: SearchResult[] = [];
|
|
80
|
+
// eslint-disable-next-line no-constant-condition
|
|
81
|
+
while (true) {
|
|
82
|
+
const match = regex.exec(html);
|
|
83
|
+
if (!match) break;
|
|
84
|
+
const slug = match[1];
|
|
85
|
+
const id = match[2];
|
|
86
|
+
|
|
87
|
+
// Try to find a better name near this match
|
|
88
|
+
const start = Math.max(0, match.index - 500);
|
|
89
|
+
const end = Math.min(html.length, match.index + 500);
|
|
90
|
+
const snippet = html.substring(start, end);
|
|
91
|
+
|
|
92
|
+
// Look for aria-label or title with the name
|
|
93
|
+
const nameMatch = snippet.match(/aria-label="([^"]+)"/);
|
|
94
|
+
const name = nameMatch ? nameMatch[1] : slug;
|
|
95
|
+
|
|
96
|
+
// Avoid duplicates
|
|
97
|
+
if (!results.find((r) => r.id === id)) {
|
|
98
|
+
results.push({ name, id, store: "edge" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Search both Chrome Web Store and Edge Add-ons for extensions matching a query.
|
|
107
|
+
*/
|
|
108
|
+
export async function searchExtensions(query: string): Promise<SearchResult[]> {
|
|
109
|
+
const [chromeResults, edgeResults] = await Promise.all([
|
|
110
|
+
searchChromeWebStore(query).catch(() => []),
|
|
111
|
+
searchEdgeAddons(query).catch(() => []),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
return [...chromeResults, ...edgeResults];
|
|
115
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface StoreInfo {
|
|
2
|
+
id: string;
|
|
3
|
+
store: "chrome" | "edge";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const CHROME_URL_RE = /chromewebstore\.google\.com\/(?:webstore\/)?detail\/[^/]+\/([a-z0-9]+)/i;
|
|
7
|
+
const EDGE_URL_RE = /microsoftedge\.microsoft\.com\/addons\/detail\/[^/]+\/([a-z0-9]+)/i;
|
|
8
|
+
const BARE_ID_RE = /^[a-z0-9]+$/i;
|
|
9
|
+
|
|
10
|
+
export function parseStoreUrl(input: string): StoreInfo {
|
|
11
|
+
const trimmed = input.trim();
|
|
12
|
+
|
|
13
|
+
// Try Chrome Web Store URL
|
|
14
|
+
const chromeMatch = trimmed.match(CHROME_URL_RE);
|
|
15
|
+
if (chromeMatch) {
|
|
16
|
+
return { id: chromeMatch[1].toLowerCase(), store: "chrome" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Try Edge Add-ons URL
|
|
20
|
+
const edgeMatch = trimmed.match(EDGE_URL_RE);
|
|
21
|
+
if (edgeMatch) {
|
|
22
|
+
return { id: edgeMatch[1].toLowerCase(), store: "edge" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Try bare extension ID (32 lowercase letters)
|
|
26
|
+
if (BARE_ID_RE.test(trimmed)) {
|
|
27
|
+
return { id: trimmed.toLowerCase(), store: "chrome" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Invalid extension URL or ID: "${input}". Expected a Chrome Web Store URL, Edge Add-ons URL, or a 32-character extension ID.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// test/crx-parser.test.ts
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { extractZipFromCrx, parseCrxHeader } from "../src/crx-parser";
|
|
4
|
+
|
|
5
|
+
describe("CRX Parser", () => {
|
|
6
|
+
test("parseCrxHeader reads CRX3 header", () => {
|
|
7
|
+
const header = Buffer.alloc(16);
|
|
8
|
+
header.write("Cr24", 0);
|
|
9
|
+
header.writeUInt32LE(3, 4);
|
|
10
|
+
header.writeUInt32LE(4, 8);
|
|
11
|
+
header.writeUInt32LE(0, 12);
|
|
12
|
+
|
|
13
|
+
const info = parseCrxHeader(header);
|
|
14
|
+
expect(info.version).toBe(3);
|
|
15
|
+
expect(info.headerLength).toBe(4);
|
|
16
|
+
expect(info.zipOffset).toBe(16);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parseCrxHeader reads CRX2 header", () => {
|
|
20
|
+
const header = Buffer.alloc(16);
|
|
21
|
+
header.write("Cr24", 0);
|
|
22
|
+
header.writeUInt32LE(2, 4);
|
|
23
|
+
header.writeUInt32LE(4, 8);
|
|
24
|
+
|
|
25
|
+
const info = parseCrxHeader(header);
|
|
26
|
+
expect(info.version).toBe(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("parseCrxHeader throws on invalid magic", () => {
|
|
30
|
+
const bad = Buffer.from("NOTACRX");
|
|
31
|
+
expect(() => parseCrxHeader(bad)).toThrow("Not a CRX file");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("extractZipFromCrx returns zip bytes after header", () => {
|
|
35
|
+
const fixed = Buffer.alloc(12);
|
|
36
|
+
fixed.write("Cr24", 0);
|
|
37
|
+
fixed.writeUInt32LE(3, 4);
|
|
38
|
+
fixed.writeUInt32LE(4, 8);
|
|
39
|
+
|
|
40
|
+
const protoHeader = Buffer.alloc(4);
|
|
41
|
+
const zipData = Buffer.from("PKZIP_DATA_HERE");
|
|
42
|
+
|
|
43
|
+
const crx = Buffer.concat([fixed, protoHeader, zipData]);
|
|
44
|
+
const zip = extractZipFromCrx(crx);
|
|
45
|
+
|
|
46
|
+
expect(zip.equals(zipData)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// test/extractor.test.ts
|
|
2
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { extractCrx } from "../src/extractor";
|
|
6
|
+
|
|
7
|
+
const TEST_DIR = join(import.meta.dir, "__test_extract__");
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("extractCrx extracts a CRX file to directory", async () => {
|
|
18
|
+
const zipPath = join(TEST_DIR, "test.zip");
|
|
19
|
+
|
|
20
|
+
const extDir = join(TEST_DIR, "ext_src");
|
|
21
|
+
mkdirSync(extDir, { recursive: true });
|
|
22
|
+
writeFileSync(join(extDir, "manifest.json"), JSON.stringify({ name: "test", version: "1.0" }));
|
|
23
|
+
|
|
24
|
+
const proc = Bun.spawn([
|
|
25
|
+
"powershell",
|
|
26
|
+
"-Command",
|
|
27
|
+
`Compress-Archive -Path '${extDir}\\*' -DestinationPath '${zipPath}' -Force`,
|
|
28
|
+
]);
|
|
29
|
+
await proc.exited;
|
|
30
|
+
|
|
31
|
+
const zipBuf = Bun.file(zipPath);
|
|
32
|
+
const zipBytes = Buffer.from(await zipBuf.arrayBuffer());
|
|
33
|
+
|
|
34
|
+
const crxHeader = Buffer.alloc(12);
|
|
35
|
+
crxHeader.write("Cr24", 0);
|
|
36
|
+
crxHeader.writeUInt32LE(3, 4);
|
|
37
|
+
crxHeader.writeUInt32LE(4, 8);
|
|
38
|
+
|
|
39
|
+
const dummyProto = Buffer.alloc(4);
|
|
40
|
+
const crxBuffer = Buffer.concat([crxHeader, dummyProto, zipBytes]);
|
|
41
|
+
|
|
42
|
+
const crxPath = join(TEST_DIR, "test.crx");
|
|
43
|
+
writeFileSync(crxPath, crxBuffer);
|
|
44
|
+
|
|
45
|
+
const outputDir = join(TEST_DIR, "extracted");
|
|
46
|
+
const result = await extractCrx(crxPath, outputDir);
|
|
47
|
+
|
|
48
|
+
expect(existsSync(join(result, "manifest.json"))).toBe(true);
|
|
49
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { packCrx } from "../src/packer";
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = join(import.meta.dir, "__test_pack__");
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("packCrx creates a CRX file from directory", async () => {
|
|
17
|
+
const extDir = join(TEST_DIR, "my-ext");
|
|
18
|
+
mkdirSync(extDir, { recursive: true });
|
|
19
|
+
writeFileSync(
|
|
20
|
+
join(extDir, "manifest.json"),
|
|
21
|
+
JSON.stringify({ manifest_version: 3, name: "Test Ext", version: "1.0" }),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const crxPath = join(TEST_DIR, "output.crx");
|
|
25
|
+
const result = await packCrx(extDir, crxPath);
|
|
26
|
+
|
|
27
|
+
expect(existsSync(result)).toBe(true);
|
|
28
|
+
|
|
29
|
+
const buf = readFileSync(result);
|
|
30
|
+
expect(buf.toString("ascii", 0, 4)).toBe("Cr24");
|
|
31
|
+
expect(buf.readUInt32LE(4)).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("packCrx generates .pem key if none exists", async () => {
|
|
35
|
+
const extDir = join(TEST_DIR, "my-ext2");
|
|
36
|
+
mkdirSync(extDir, { recursive: true });
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(extDir, "manifest.json"),
|
|
39
|
+
JSON.stringify({ manifest_version: 3, name: "Test", version: "1.0" }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await packCrx(extDir, join(TEST_DIR, "out.crx"));
|
|
43
|
+
expect(existsSync(join(extDir, "key.pem"))).toBe(true);
|
|
44
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseStoreUrl } from "../src/url-parser";
|
|
3
|
+
|
|
4
|
+
describe("parseStoreUrl", () => {
|
|
5
|
+
test("parses Chrome Web Store URL", () => {
|
|
6
|
+
const result = parseStoreUrl(
|
|
7
|
+
"https://chromewebstore.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm",
|
|
8
|
+
);
|
|
9
|
+
expect(result).toEqual({
|
|
10
|
+
id: "cjpalhdlnbpafiamejdnhcphjbkeiagm",
|
|
11
|
+
store: "chrome",
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("parses Chrome URL with query params", () => {
|
|
16
|
+
const result = parseStoreUrl(
|
|
17
|
+
"https://chromewebstore.google.com/detail/ext-name/abcdef1234567890abcdef12?hl=en",
|
|
18
|
+
);
|
|
19
|
+
expect(result).toEqual({ id: "abcdef1234567890abcdef12", store: "chrome" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parses Edge Add-ons URL", () => {
|
|
23
|
+
const result = parseStoreUrl(
|
|
24
|
+
"https://microsoftedge.microsoft.com/addons/detail/ublock-origin/odfafepnkmbhccpbejgmiehpchacaeak",
|
|
25
|
+
);
|
|
26
|
+
expect(result).toEqual({
|
|
27
|
+
id: "odfafepnkmbhccpbejgmiehpchacaeak",
|
|
28
|
+
store: "edge",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("parses bare 32-char extension ID as chrome", () => {
|
|
33
|
+
const result = parseStoreUrl("cjpalhdlnbpafiamejdnhcphjbkeiagm");
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
id: "cjpalhdlnbpafiamejdnhcphjbkeiagm",
|
|
36
|
+
store: "chrome",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("throws on invalid input", () => {
|
|
41
|
+
expect(() => parseStoreUrl("")).toThrow();
|
|
42
|
+
expect(() => parseStoreUrl("not-a-valid-id!")).toThrow();
|
|
43
|
+
});
|
|
44
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"types": ["bun-types"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
17
|
+
}
|