@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.
- package/CHANGELOG.md +14 -0
- package/README.md +77 -1
- package/dist/commands/extension/add.d.ts +9 -0
- package/dist/commands/extension/add.js +41 -0
- package/dist/commands/extension/list.d.ts +6 -0
- package/dist/commands/extension/list.js +27 -0
- package/dist/commands/extension/remove.d.ts +9 -0
- package/dist/commands/extension/remove.js +38 -0
- package/dist/commands/extension/update.d.ts +6 -0
- package/dist/commands/extension/update.js +20 -0
- package/dist/helpers/cli-config.d.ts +11 -0
- package/dist/helpers/cli-config.js +4 -0
- package/dist/helpers/cli-config.spec.js +13 -0
- package/dist/hooks/init/first-run.js +1 -0
- package/dist/services/integration-api/spec/file-watch.spec.d.ts +1 -0
- package/dist/services/integration-api/spec/file-watch.spec.js +81 -0
- package/dist/services/integration-api/struct/file.js +17 -5
- package/dist/services/setup/extension/crx.d.ts +13 -0
- package/dist/services/setup/extension/crx.js +48 -0
- package/dist/services/setup/extension/crx.spec.d.ts +1 -0
- package/dist/services/setup/extension/crx.spec.js +38 -0
- package/dist/services/setup/extension/extension-installer.d.ts +31 -0
- package/dist/services/setup/extension/extension-installer.js +87 -0
- package/dist/services/setup/extension/extension-setup-service.d.ts +40 -0
- package/dist/services/setup/extension/extension-setup-service.js +152 -0
- package/dist/services/setup/extension/webstore.d.ts +33 -0
- package/dist/services/setup/extension/webstore.js +74 -0
- package/dist/services/setup/extension/webstore.spec.d.ts +1 -0
- package/dist/services/setup/extension/webstore.spec.js +51 -0
- package/dist/services/setup/index.d.ts +4 -0
- package/dist/services/setup/index.js +4 -0
- package/dist/services/setup/setup-service-factory.js +5 -1
- package/dist/services/setup/setup-service.d.ts +11 -2
- package/dist/services/setup/setup-service.js +18 -3
- package/oclif.manifest.json +112 -1
- 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.
|
|
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,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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
+
}
|