@speedkit/cli 4.20.4 → 4.21.0

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +77 -1
  3. package/dist/commands/extension/add.d.ts +9 -0
  4. package/dist/commands/extension/add.js +41 -0
  5. package/dist/commands/extension/list.d.ts +6 -0
  6. package/dist/commands/extension/list.js +27 -0
  7. package/dist/commands/extension/remove.d.ts +9 -0
  8. package/dist/commands/extension/remove.js +38 -0
  9. package/dist/commands/extension/update.d.ts +6 -0
  10. package/dist/commands/extension/update.js +20 -0
  11. package/dist/helpers/cli-config.d.ts +11 -0
  12. package/dist/helpers/cli-config.js +4 -0
  13. package/dist/helpers/cli-config.spec.js +13 -0
  14. package/dist/hooks/init/first-run.js +1 -0
  15. package/dist/services/integration-api/spec/file-watch.spec.d.ts +1 -0
  16. package/dist/services/integration-api/spec/file-watch.spec.js +81 -0
  17. package/dist/services/integration-api/struct/file.js +17 -5
  18. package/dist/services/setup/extension/crx.d.ts +13 -0
  19. package/dist/services/setup/extension/crx.js +48 -0
  20. package/dist/services/setup/extension/crx.spec.d.ts +1 -0
  21. package/dist/services/setup/extension/crx.spec.js +38 -0
  22. package/dist/services/setup/extension/extension-installer.d.ts +31 -0
  23. package/dist/services/setup/extension/extension-installer.js +87 -0
  24. package/dist/services/setup/extension/extension-setup-service.d.ts +40 -0
  25. package/dist/services/setup/extension/extension-setup-service.js +152 -0
  26. package/dist/services/setup/extension/webstore.d.ts +33 -0
  27. package/dist/services/setup/extension/webstore.js +74 -0
  28. package/dist/services/setup/extension/webstore.spec.d.ts +1 -0
  29. package/dist/services/setup/extension/webstore.spec.js +51 -0
  30. package/dist/services/setup/index.d.ts +4 -0
  31. package/dist/services/setup/index.js +4 -0
  32. package/dist/services/setup/setup-service-factory.js +5 -1
  33. package/dist/services/setup/setup-service.d.ts +11 -2
  34. package/dist/services/setup/setup-service.js +18 -3
  35. package/oclif.manifest.json +112 -1
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [4.21.0](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.5...v4.21.0) (2026-08-14)
2
+
3
+
4
+ ### Features
5
+
6
+ * implement extensionManagement ([17e65b7](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/17e65b72c8e7791ba1174dd7c948daffe4a9b9cc))
7
+
8
+ ## [4.20.5](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.4...v4.20.5) (2026-08-10)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **onboarding:** keep watching a config file after git replaces it ([ef2571a](https://gitlab.orestes.info/baqend/speed-kit-cli/commit/ef2571a5fa07862382c338096e3311c107f20a74))
14
+
1
15
  ## [4.20.4](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.3...v4.20.4) (2026-08-10)
2
16
 
3
17
 
package/README.md CHANGED
@@ -21,7 +21,7 @@ $ npm install -g @speedkit/cli
21
21
  $ sk COMMAND
22
22
  running command...
23
23
  $ sk (--version)
24
- @speedkit/cli/4.20.4 linux-x64 node-v22.23.2
24
+ @speedkit/cli/4.21.0 linux-x64 node-v22.23.2
25
25
  $ sk --help [COMMAND]
26
26
  USAGE
27
27
  $ sk COMMAND
@@ -78,6 +78,10 @@ By either...
78
78
  * [`sk deploy CUSTOMERPATH`](#sk-deploy-customerpath)
79
79
  * [`sk diff-url-parameters PAGEURL`](#sk-diff-url-parameters-pageurl)
80
80
  * [`sk document-handler CUSTOMERPATH`](#sk-document-handler-customerpath)
81
+ * [`sk extension add [REF]`](#sk-extension-add-ref)
82
+ * [`sk extension list`](#sk-extension-list)
83
+ * [`sk extension remove [REF]`](#sk-extension-remove-ref)
84
+ * [`sk extension update`](#sk-extension-update)
81
85
  * [`sk find-pops APP`](#sk-find-pops-app)
82
86
  * [`sk generate-customer-config`](#sk-generate-customer-config)
83
87
  * [`sk generate-pop-config APP`](#sk-generate-pop-config-app)
@@ -381,6 +385,78 @@ EXAMPLES
381
385
  $ sk deploy customers/decathlon.de -c production
382
386
  ```
383
387
 
388
+ ## `sk extension add [REF]`
389
+
390
+ Install a Chrome extension into the onboarding browser by Web Store link or id
391
+
392
+ ```
393
+ USAGE
394
+ $ sk extension add [REF]
395
+
396
+ ARGUMENTS
397
+ [REF] Chrome Web Store link or extension id
398
+
399
+ DESCRIPTION
400
+ Install a Chrome extension into the onboarding browser by Web Store link or id
401
+
402
+ EXAMPLES
403
+ $ sk extension add fmkadmapgofadopljbjfkapdkoienihi
404
+
405
+ $ sk extension add https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
406
+
407
+ $ sk extension add
408
+ ```
409
+
410
+ ## `sk extension list`
411
+
412
+ List Chrome extensions managed by the setup wizard
413
+
414
+ ```
415
+ USAGE
416
+ $ sk extension list
417
+
418
+ DESCRIPTION
419
+ List Chrome extensions managed by the setup wizard
420
+
421
+ EXAMPLES
422
+ $ sk extension list
423
+ ```
424
+
425
+ ## `sk extension remove [REF]`
426
+
427
+ Remove a managed Chrome extension
428
+
429
+ ```
430
+ USAGE
431
+ $ sk extension remove [REF]
432
+
433
+ ARGUMENTS
434
+ [REF] Chrome Web Store link or extension id
435
+
436
+ DESCRIPTION
437
+ Remove a managed Chrome extension
438
+
439
+ EXAMPLES
440
+ $ sk extension remove
441
+
442
+ $ sk extension remove <id>
443
+ ```
444
+
445
+ ## `sk extension update`
446
+
447
+ Check managed Chrome extensions for updates and install them
448
+
449
+ ```
450
+ USAGE
451
+ $ sk extension update
452
+
453
+ DESCRIPTION
454
+ Check managed Chrome extensions for updates and install them
455
+
456
+ EXAMPLES
457
+ $ sk extension update
458
+ ```
459
+
384
460
  ## `sk find-pops APP`
385
461
 
386
462
  Get IPs for all fastly PoPs
@@ -0,0 +1,9 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class ExtensionAdd extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ ref: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,41 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { CliServiceFactory } from "../../services/cli/index.js";
3
+ import { SetupServiceFactory } from "../../services/setup/index.js";
4
+ export default class ExtensionAdd extends Command {
5
+ static description = "Install a Chrome extension into the onboarding browser by Web Store link or id";
6
+ static examples = [
7
+ "$ sk extension add fmkadmapgofadopljbjfkapdkoienihi",
8
+ "$ sk extension add https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi",
9
+ "$ sk extension add",
10
+ ];
11
+ static args = {
12
+ ref: Args.string({
13
+ name: "ref",
14
+ required: false,
15
+ description: "Chrome Web Store link or extension id",
16
+ }),
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(ExtensionAdd);
20
+ const cli = new CliServiceFactory().getService();
21
+ const { extensions } = new SetupServiceFactory(this.config).getService();
22
+ let ref = args.ref;
23
+ if (!ref) {
24
+ ref = await cli.prompt("Paste a Chrome Web Store link or extension id", {
25
+ defaultAnswer: "",
26
+ });
27
+ }
28
+ if (!ref?.trim()) {
29
+ cli.writeWarning("No extension provided.");
30
+ return;
31
+ }
32
+ cli.writeWarning("Only add extensions you trust — they are loaded into the onboarding browser.");
33
+ try {
34
+ const ext = await extensions.addFromInput(ref);
35
+ cli.writeSuccess(`Installed ${ext.name} ${ext.version}`);
36
+ }
37
+ catch (error) {
38
+ cli.writeError(`Could not install extension: ${error?.message ?? error}`);
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,6 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class ExtensionList extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,27 @@
1
+ import { Command } from "@oclif/core";
2
+ import { CliServiceFactory } from "../../services/cli/index.js";
3
+ import { SetupServiceFactory } from "../../services/setup/index.js";
4
+ export default class ExtensionList extends Command {
5
+ static description = "List Chrome extensions managed by the setup wizard";
6
+ static examples = ["$ sk extension list"];
7
+ async run() {
8
+ const cli = new CliServiceFactory().getService();
9
+ const { extensions } = new SetupServiceFactory(this.config).getService();
10
+ const list = extensions.list();
11
+ if (list.length === 0) {
12
+ cli.writeWarning("No managed extensions installed.");
13
+ return;
14
+ }
15
+ cli.table(list.map((ext) => ({
16
+ name: ext.name,
17
+ version: ext.version,
18
+ id: ext.id,
19
+ path: ext.path,
20
+ })), {
21
+ name: { header: "Name" },
22
+ version: { header: "Version" },
23
+ id: { header: "Id" },
24
+ path: { header: "Path" },
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,9 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class ExtensionRemove extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ ref: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,38 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { CliServiceFactory } from "../../services/cli/index.js";
3
+ import { parseExtensionId, SetupServiceFactory, } from "../../services/setup/index.js";
4
+ export default class ExtensionRemove extends Command {
5
+ static description = "Remove a managed Chrome extension";
6
+ static examples = ["$ sk extension remove", "$ sk extension remove <id>"];
7
+ static args = {
8
+ ref: Args.string({
9
+ name: "ref",
10
+ required: false,
11
+ description: "Chrome Web Store link or extension id",
12
+ }),
13
+ };
14
+ async run() {
15
+ const { args } = await this.parse(ExtensionRemove);
16
+ const cli = new CliServiceFactory().getService();
17
+ const { extensions } = new SetupServiceFactory(this.config).getService();
18
+ const list = extensions.list();
19
+ if (list.length === 0) {
20
+ cli.writeWarning("No managed extensions to remove.");
21
+ return;
22
+ }
23
+ let id = args.ref ? parseExtensionId(args.ref) : null;
24
+ if (!id) {
25
+ id = await cli.select("Which extension should be removed?", list.map((ext) => ({
26
+ name: `${ext.name} (${ext.version})`,
27
+ value: ext.id,
28
+ })));
29
+ }
30
+ const removed = await extensions.remove(id);
31
+ if (removed) {
32
+ cli.writeSuccess(`Removed extension ${id}`);
33
+ }
34
+ else {
35
+ cli.writeWarning(`Extension not found: ${id}`);
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class ExtensionUpdate extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,20 @@
1
+ import { Command } from "@oclif/core";
2
+ import { CliServiceFactory } from "../../services/cli/index.js";
3
+ import { SetupServiceFactory } from "../../services/setup/index.js";
4
+ export default class ExtensionUpdate extends Command {
5
+ static description = "Check managed Chrome extensions for updates and install them";
6
+ static examples = ["$ sk extension update"];
7
+ async run() {
8
+ const cli = new CliServiceFactory().getService();
9
+ const { extensions } = new SetupServiceFactory(this.config).getService();
10
+ const list = extensions.list();
11
+ if (list.length === 0) {
12
+ cli.writeWarning("No managed extensions to update.");
13
+ return;
14
+ }
15
+ cli.startAction("EXT:CHECK", "Checking extensions for updates…");
16
+ cli.endAction("EXT:CHECK");
17
+ await extensions.updateAll();
18
+ cli.writeSuccess("Extension update check complete.");
19
+ }
20
+ }
@@ -1,4 +1,13 @@
1
1
  import { Config } from "@oclif/core";
2
+ /** A Chrome extension installed via the setup wizard and tracked for updates. */
3
+ export interface ManagedExtension {
4
+ /** Chrome Web Store extension id */
5
+ id: string;
6
+ name: string;
7
+ version: string;
8
+ /** absolute path to the unpacked extension directory */
9
+ path: string;
10
+ }
2
11
  export interface UserCliConfig {
3
12
  chromeExtensionPaths: string;
4
13
  chromePath: string;
@@ -14,6 +23,8 @@ export interface UserCliConfig {
14
23
  browserAutoUpdate: boolean;
15
24
  updateReminderDays: number;
16
25
  lastBrowserUpdateCheck: string;
26
+ managedChromeExtensions: ManagedExtension[];
27
+ lastExtensionUpdateCheck: string;
17
28
  firstRunCompleted: boolean;
18
29
  tempFolder: string;
19
30
  runtimeConfig: Config;
@@ -36,6 +36,8 @@ export class CliConfig {
36
36
  browserAutoUpdate: false,
37
37
  updateReminderDays: 3,
38
38
  lastBrowserUpdateCheck: "",
39
+ managedChromeExtensions: [],
40
+ lastExtensionUpdateCheck: "",
39
41
  firstRunCompleted: false,
40
42
  tempFolder: "/tmp",
41
43
  runtimeConfig: {},
@@ -74,6 +76,8 @@ export class CliConfig {
74
76
  browserAutoUpdate: false,
75
77
  updateReminderDays: 3,
76
78
  lastBrowserUpdateCheck: "",
79
+ managedChromeExtensions: [],
80
+ lastExtensionUpdateCheck: "",
77
81
  firstRunCompleted: false,
78
82
  };
79
83
  }
@@ -67,4 +67,17 @@ describe("CliConfig save/load", () => {
67
67
  const reloaded = new CliConfig(fakeOclifConfig(dir)).load();
68
68
  expect(reloaded.browserAutoUpdate).to.equal(false);
69
69
  });
70
+ it("round-trips the managedChromeExtensions array", () => {
71
+ const cfg = new CliConfig(fakeOclifConfig(dir));
72
+ cfg.load();
73
+ cfg.save({
74
+ managedChromeExtensions: [
75
+ { id: "abc", name: "Ext", version: "1.0.0", path: "/tmp/ext/abc" },
76
+ ],
77
+ });
78
+ const reloaded = new CliConfig(fakeOclifConfig(dir)).load();
79
+ expect(reloaded.managedChromeExtensions).to.have.length(1);
80
+ expect(reloaded.managedChromeExtensions[0].id).to.equal("abc");
81
+ expect(reloaded.managedChromeExtensions[0].version).to.equal("1.0.0");
82
+ });
70
83
  });
@@ -28,6 +28,7 @@ const hook = async function (opts) {
28
28
  }
29
29
  const service = new SetupServiceFactory(config).getService();
30
30
  await service.remindBrowserUpdate();
31
+ await service.remindExtensionUpdates();
31
32
  }
32
33
  catch {
33
34
  // never block a command because of setup housekeeping
@@ -0,0 +1,81 @@
1
+ import { expect } from "chai";
2
+ import { describe, it, afterEach } from "mocha";
3
+ import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ // via the model barrel, not struct/file.js directly: the struct modules form an
7
+ // import cycle that only resolves in this order.
8
+ import { File } from "../integration-api-model.js";
9
+ // chokidar needs a real filesystem, so these run against a temp dir rather than memfs.
10
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
+ /** Wait until the change callback has fired at least `count` times. */
12
+ async function waitForCalls(getCalls, count, timeoutMs = 5000) {
13
+ const deadline = Date.now() + timeoutMs;
14
+ while (Date.now() < deadline) {
15
+ if (getCalls() >= count)
16
+ return;
17
+ await delay(50);
18
+ }
19
+ }
20
+ describe("File onChange", function () {
21
+ // real filesystem events plus chokidar's write-finish debounce
22
+ this.timeout(20000);
23
+ let dir;
24
+ let controller;
25
+ afterEach(() => {
26
+ controller?.abort();
27
+ controller = undefined;
28
+ if (dir) {
29
+ rmSync(dir, { recursive: true, force: true });
30
+ dir = undefined;
31
+ }
32
+ });
33
+ it("keeps reporting edits after the file is replaced instead of written in place", async () => {
34
+ dir = mkdtempSync(join(tmpdir(), "sk-file-watch-"));
35
+ const path = join(dir, "config_documentHandler.js");
36
+ writeFileSync(path, "v0");
37
+ const file = new File("config_documentHandler", path, "v0");
38
+ controller = new AbortController();
39
+ let calls = 0;
40
+ file.onChange(async () => {
41
+ calls += 1;
42
+ }, { signal: controller.signal });
43
+ await delay(700); // let the watcher attach
44
+ writeFileSync(path, "v1");
45
+ await waitForCalls(() => calls, 1);
46
+ expect(calls, "an in-place write is reported").to.be.at.least(1);
47
+ // git checkout / reset / rebase / stash / pull replace the file rather than
48
+ // writing into it. A single-file watch stops firing for good at this point.
49
+ const callsBeforeReplace = calls;
50
+ unlinkSync(path);
51
+ writeFileSync(path, "v2");
52
+ await waitForCalls(() => calls, callsBeforeReplace + 1);
53
+ expect(calls, "the replacement itself is reported").to.be.at.least(callsBeforeReplace + 1);
54
+ // The regression: every edit after the replacement used to be dropped, so
55
+ // onboarding kept serving the previous build until it was restarted.
56
+ const callsAfterReplace = calls;
57
+ writeFileSync(path, "v3");
58
+ await waitForCalls(() => calls, callsAfterReplace + 1);
59
+ expect(calls, "an edit after the replacement is still reported").to.be.at.least(callsAfterReplace + 1);
60
+ });
61
+ it("ignores changes to sibling files in the same directory", async () => {
62
+ dir = mkdtempSync(join(tmpdir(), "sk-file-watch-"));
63
+ const path = join(dir, "config_documentHandler.js");
64
+ const sibling = join(dir, "config_SpeedKit.js");
65
+ writeFileSync(path, "v0");
66
+ writeFileSync(sibling, "other");
67
+ const file = new File("config_documentHandler", path, "v0");
68
+ controller = new AbortController();
69
+ let calls = 0;
70
+ file.onChange(async () => {
71
+ calls += 1;
72
+ }, { signal: controller.signal });
73
+ await delay(700);
74
+ writeFileSync(sibling, "other-changed");
75
+ await delay(900);
76
+ expect(calls, "a sibling write is not reported").to.equal(0);
77
+ writeFileSync(path, "v1");
78
+ await waitForCalls(() => calls, 1);
79
+ expect(calls, "the watched file is still reported").to.be.at.least(1);
80
+ });
81
+ });
@@ -1,4 +1,5 @@
1
1
  import { readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
2
3
  import { watch as chokidarWatch } from "chokidar";
3
4
  import { FILE_TYPE, } from "../integration-api-model.js";
4
5
  export class File {
@@ -47,15 +48,26 @@ export class File {
47
48
  if (signal?.aborted) {
48
49
  return;
49
50
  }
50
- // chokidar over node's fs.watch: handles atomic write-then-rename
51
- // (Claude Code, VS Code's atomicSave, vim writebackup, …) which fs.watch
52
- // misses because inotify is inode-bound and the new file gets a new inode.
53
- const watcher = chokidarWatch(this.path, {
51
+ // Watch the containing directory, not the file: a single-file watch stops
52
+ // firing for good once the file is *replaced* rather than written in place,
53
+ // which is what git does on checkout/reset/rebase/stash/pull. The replace
54
+ // itself still arrives, so the breakage is silent — every later edit is
55
+ // dropped, no rebuild and no cache clear run, and onboarding keeps serving
56
+ // the previous build until it is restarted.
57
+ // depth 0 keeps this to the directory's own entries, and chokidar's atomic
58
+ // option still folds write-then-rename (Claude Code, VS Code atomicSave,
59
+ // vim writebackup, …) into a single change event.
60
+ const target = resolve(this.path);
61
+ const watcher = chokidarWatch(dirname(target), {
54
62
  ignoreInitial: true,
55
63
  atomic: true,
64
+ depth: 0,
56
65
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
57
66
  });
58
- const handle = async () => {
67
+ const handle = async (changedPath) => {
68
+ if (resolve(changedPath) !== target) {
69
+ return;
70
+ }
59
71
  const content = await readFile(this.path, "utf-8");
60
72
  if (content === this.content) {
61
73
  return;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A CRX file is a ZIP archive prefixed with a header. Because the header shifts
3
+ * the archive's byte offsets, a plain unzip cannot read it directly — the header
4
+ * has to be stripped first. This returns the byte offset at which the embedded
5
+ * ZIP begins. A buffer without the `Cr24` magic is assumed to already be a ZIP
6
+ * (offset 0).
7
+ */
8
+ export declare function getZipOffset(buffer: Buffer): number;
9
+ /**
10
+ * Extracts a CRX (or plain ZIP) buffer into `destDir` as an unpacked extension.
11
+ * The destination is cleared first so re-installs/updates replace it cleanly.
12
+ */
13
+ export declare function extractCrx(crxBuffer: Buffer, destDir: string, tempFolder: string): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import extract from "extract-zip";
4
+ /**
5
+ * A CRX file is a ZIP archive prefixed with a header. Because the header shifts
6
+ * the archive's byte offsets, a plain unzip cannot read it directly — the header
7
+ * has to be stripped first. This returns the byte offset at which the embedded
8
+ * ZIP begins. A buffer without the `Cr24` magic is assumed to already be a ZIP
9
+ * (offset 0).
10
+ */
11
+ export function getZipOffset(buffer) {
12
+ const magic = buffer.toString("utf8", 0, 4);
13
+ if (magic !== "Cr24") {
14
+ return 0;
15
+ }
16
+ const version = buffer.readUInt32LE(4);
17
+ // CRX2: magic(4) version(4) pubKeyLen(4) sigLen(4) pubKey sig zip
18
+ if (version === 2) {
19
+ const publicKeyLength = buffer.readUInt32LE(8);
20
+ const signatureLength = buffer.readUInt32LE(12);
21
+ return 16 + publicKeyLength + signatureLength;
22
+ }
23
+ // CRX3: magic(4) version(4) headerLen(4) header zip
24
+ if (version === 3) {
25
+ const headerLength = buffer.readUInt32LE(8);
26
+ return 12 + headerLength;
27
+ }
28
+ throw new Error(`Unsupported CRX version: ${version}`);
29
+ }
30
+ /**
31
+ * Extracts a CRX (or plain ZIP) buffer into `destDir` as an unpacked extension.
32
+ * The destination is cleared first so re-installs/updates replace it cleanly.
33
+ */
34
+ export async function extractCrx(crxBuffer, destDir, tempFolder) {
35
+ const offset = getZipOffset(crxBuffer);
36
+ const zipBuffer = offset > 0 ? crxBuffer.subarray(offset) : crxBuffer;
37
+ const resolvedDest = path.resolve(destDir);
38
+ // extract-zip needs a file path, so stage the embedded zip on disk first
39
+ const tempZipPath = path.resolve(tempFolder, `${path.basename(resolvedDest)}.crx.zip`);
40
+ fs.writeFileSync(tempZipPath, zipBuffer);
41
+ fs.rmSync(resolvedDest, { recursive: true, force: true });
42
+ try {
43
+ await extract(tempZipPath, { dir: resolvedDest });
44
+ }
45
+ finally {
46
+ fs.rmSync(tempZipPath, { force: true });
47
+ }
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { getZipOffset } from "./crx.js";
4
+ function crx3Header(headerLength) {
5
+ const buffer = Buffer.alloc(12);
6
+ buffer.write("Cr24", 0, "utf8");
7
+ buffer.writeUInt32LE(3, 4);
8
+ buffer.writeUInt32LE(headerLength, 8);
9
+ return buffer;
10
+ }
11
+ function crx2Header(publicKeyLength, signatureLength) {
12
+ const buffer = Buffer.alloc(16);
13
+ buffer.write("Cr24", 0, "utf8");
14
+ buffer.writeUInt32LE(2, 4);
15
+ buffer.writeUInt32LE(publicKeyLength, 8);
16
+ buffer.writeUInt32LE(signatureLength, 12);
17
+ return buffer;
18
+ }
19
+ describe("crx.getZipOffset", () => {
20
+ it("returns 0 for a plain zip (no Cr24 magic)", () => {
21
+ const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]);
22
+ expect(getZipOffset(zip)).to.equal(0);
23
+ });
24
+ it("computes the offset for a CRX3 file", () => {
25
+ // 12 fixed bytes + headerLength
26
+ expect(getZipOffset(crx3Header(42))).to.equal(54);
27
+ });
28
+ it("computes the offset for a CRX2 file", () => {
29
+ // 16 fixed bytes + pubKeyLen + sigLen
30
+ expect(getZipOffset(crx2Header(3, 4))).to.equal(23);
31
+ });
32
+ it("throws on an unsupported CRX version", () => {
33
+ const buffer = Buffer.alloc(12);
34
+ buffer.write("Cr24", 0, "utf8");
35
+ buffer.writeUInt32LE(9, 4);
36
+ expect(() => getZipOffset(buffer)).to.throw(/Unsupported CRX version/);
37
+ });
38
+ });
@@ -0,0 +1,31 @@
1
+ import { ManagedExtension } from "../../../helpers/cli-config.js";
2
+ /**
3
+ * Derives the Chrome major version to send as `prodversion` to Google's update
4
+ * service. Prefers the configured puppeteer build, falling back to a recent
5
+ * constant (the endpoint is lenient about this value).
6
+ */
7
+ export declare function resolveChromeMajorVersion(browserBuildId: string): string;
8
+ /**
9
+ * Downloads, extracts and inspects Chrome Web Store extensions. Purely handles
10
+ * files and network; persistence/prompts live in the setup service.
11
+ */
12
+ export declare class ExtensionInstaller {
13
+ private readonly tempFolder;
14
+ private readonly chromeMajorVersion;
15
+ constructor(tempFolder: string, chromeMajorVersion: string);
16
+ get extensionsDir(): string;
17
+ /**
18
+ * Installs an extension from a bare id or a Web Store URL and returns its
19
+ * tracked metadata. Throws on an unparseable input or a failed download.
20
+ */
21
+ install(input: string): Promise<ManagedExtension>;
22
+ /**
23
+ * Returns the latest available version for an extension, or null when the
24
+ * check fails or reports no update.
25
+ */
26
+ checkLatestVersion(id: string): Promise<string | null>;
27
+ /** Removes an unpacked extension directory from disk. */
28
+ removeFiles(extensionPath: string): void;
29
+ private download;
30
+ private readManifest;
31
+ }