@speedkit/cli 4.20.5 → 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 +7 -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/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,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [4.20.5](https://gitlab.orestes.info/baqend/speed-kit-cli/compare/v4.20.4...v4.20.5) (2026-08-10)
|
|
2
9
|
|
|
3
10
|
|
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,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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { extractCrx } from "./crx.js";
|
|
4
|
+
import { buildCrxUrl, buildUpdateCheckUrl, parseExtensionId, parseUpdateCheckVersion, } from "./webstore.js";
|
|
5
|
+
const EXTENSIONS_DIRNAME = "extensions";
|
|
6
|
+
const FALLBACK_CHROME_MAJOR = "131";
|
|
7
|
+
/**
|
|
8
|
+
* Derives the Chrome major version to send as `prodversion` to Google's update
|
|
9
|
+
* service. Prefers the configured puppeteer build, falling back to a recent
|
|
10
|
+
* constant (the endpoint is lenient about this value).
|
|
11
|
+
*/
|
|
12
|
+
export function resolveChromeMajorVersion(browserBuildId) {
|
|
13
|
+
const major = (browserBuildId ?? "").split(".")[0];
|
|
14
|
+
return /^\d+$/.test(major) ? major : FALLBACK_CHROME_MAJOR;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Downloads, extracts and inspects Chrome Web Store extensions. Purely handles
|
|
18
|
+
* files and network; persistence/prompts live in the setup service.
|
|
19
|
+
*/
|
|
20
|
+
export class ExtensionInstaller {
|
|
21
|
+
tempFolder;
|
|
22
|
+
chromeMajorVersion;
|
|
23
|
+
constructor(tempFolder, chromeMajorVersion) {
|
|
24
|
+
this.tempFolder = tempFolder;
|
|
25
|
+
this.chromeMajorVersion = chromeMajorVersion;
|
|
26
|
+
}
|
|
27
|
+
get extensionsDir() {
|
|
28
|
+
return path.resolve(this.tempFolder, EXTENSIONS_DIRNAME);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Installs an extension from a bare id or a Web Store URL and returns its
|
|
32
|
+
* tracked metadata. Throws on an unparseable input or a failed download.
|
|
33
|
+
*/
|
|
34
|
+
async install(input) {
|
|
35
|
+
const id = parseExtensionId(input);
|
|
36
|
+
if (!id) {
|
|
37
|
+
throw new Error(`Could not parse a Chrome extension id from: "${input}"`);
|
|
38
|
+
}
|
|
39
|
+
const crxBuffer = await this.download(buildCrxUrl(id, this.chromeMajorVersion));
|
|
40
|
+
const destDir = path.resolve(this.extensionsDir, id);
|
|
41
|
+
await extractCrx(crxBuffer, destDir, this.tempFolder);
|
|
42
|
+
const manifest = this.readManifest(destDir, id);
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
name: manifest.name,
|
|
46
|
+
version: manifest.version,
|
|
47
|
+
path: destDir,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the latest available version for an extension, or null when the
|
|
52
|
+
* check fails or reports no update.
|
|
53
|
+
*/
|
|
54
|
+
async checkLatestVersion(id) {
|
|
55
|
+
const url = buildUpdateCheckUrl(id, this.chromeMajorVersion);
|
|
56
|
+
const response = await fetch(url);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const xml = await response.text();
|
|
61
|
+
return parseUpdateCheckVersion(xml);
|
|
62
|
+
}
|
|
63
|
+
/** Removes an unpacked extension directory from disk. */
|
|
64
|
+
removeFiles(extensionPath) {
|
|
65
|
+
fs.rmSync(extensionPath, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
async download(url) {
|
|
68
|
+
const response = await fetch(url);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Failed to download extension: HTTP ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
return Buffer.from(await response.arrayBuffer());
|
|
73
|
+
}
|
|
74
|
+
readManifest(dir, id) {
|
|
75
|
+
const manifestPath = path.resolve(dir, "manifest.json");
|
|
76
|
+
if (!fs.existsSync(manifestPath)) {
|
|
77
|
+
throw new Error(`No manifest.json in extracted extension: ${dir}`);
|
|
78
|
+
}
|
|
79
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
80
|
+
const rawName = manifest.name ?? "";
|
|
81
|
+
// extension names can be i18n placeholders (__MSG_appName__); fall back to id
|
|
82
|
+
const name = /^__MSG_.*__$/.test(rawName)
|
|
83
|
+
? `Chrome extension ${id}`
|
|
84
|
+
: rawName || `Chrome extension ${id}`;
|
|
85
|
+
return { name, version: manifest.version ?? "0.0.0" };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CliServiceInterface } from "../../cli/index.js";
|
|
2
|
+
import { CliConfig, ManagedExtension, UserCliConfig } from "../../../helpers/cli-config.js";
|
|
3
|
+
import { ExtensionInstaller } from "./extension-installer.js";
|
|
4
|
+
export declare class ExtensionSetupService {
|
|
5
|
+
private readonly cli;
|
|
6
|
+
private readonly cliConfig;
|
|
7
|
+
private readonly userConfig;
|
|
8
|
+
private readonly installer;
|
|
9
|
+
constructor(cli: CliServiceInterface, cliConfig: CliConfig, userConfig: UserCliConfig, installer: ExtensionInstaller);
|
|
10
|
+
/**
|
|
11
|
+
* Wizard step: optionally install one or more Chrome extensions by id/URL.
|
|
12
|
+
*/
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
/** Installs (or reinstalls) an extension from input and persists it. */
|
|
15
|
+
addFromInput(input: string): Promise<ManagedExtension>;
|
|
16
|
+
list(): ManagedExtension[];
|
|
17
|
+
/** Removes an installed extension (files + config). Returns false if unknown. */
|
|
18
|
+
remove(id: string): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Daily update check across all managed extensions; prompts before each
|
|
21
|
+
* update. Callers must ensure the session is interactive first.
|
|
22
|
+
*/
|
|
23
|
+
remindIfDue(): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Checks every managed extension for updates now (ignores the daily gate).
|
|
26
|
+
* Used by `sk extension update`.
|
|
27
|
+
*/
|
|
28
|
+
updateAll(): Promise<void>;
|
|
29
|
+
private remindOne;
|
|
30
|
+
private current;
|
|
31
|
+
private upsert;
|
|
32
|
+
/**
|
|
33
|
+
* Persists the managed list and mirrors the unpacked dirs into
|
|
34
|
+
* chromeExtensionPaths (the list the onboarding browser actually loads),
|
|
35
|
+
* preserving any manually configured paths outside our extensions folder.
|
|
36
|
+
*/
|
|
37
|
+
private persist;
|
|
38
|
+
private isCheckDue;
|
|
39
|
+
private nowIso;
|
|
40
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { compareVersions } from "./webstore.js";
|
|
2
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
3
|
+
export class ExtensionSetupService {
|
|
4
|
+
cli;
|
|
5
|
+
cliConfig;
|
|
6
|
+
userConfig;
|
|
7
|
+
installer;
|
|
8
|
+
constructor(cli, cliConfig, userConfig, installer) {
|
|
9
|
+
this.cli = cli;
|
|
10
|
+
this.cliConfig = cliConfig;
|
|
11
|
+
this.userConfig = userConfig;
|
|
12
|
+
this.installer = installer;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Wizard step: optionally install one or more Chrome extensions by id/URL.
|
|
16
|
+
*/
|
|
17
|
+
async run() {
|
|
18
|
+
const wanted = await this.cli.confirm("Do you want to install Chrome extensions into the onboarding browser?", false);
|
|
19
|
+
if (!wanted) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.cli.writeWarning("Only add extensions you trust — they are loaded into the onboarding browser.");
|
|
23
|
+
let addMore = true;
|
|
24
|
+
while (addMore) {
|
|
25
|
+
const input = await this.cli.prompt("Paste a Chrome Web Store link or extension id (leave empty to stop)", { defaultAnswer: "" });
|
|
26
|
+
if (!input.trim()) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const ext = await this.addFromInput(input);
|
|
31
|
+
this.cli.writeSuccess(`Installed ${ext.name} ${ext.version}`);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
this.cli.writeError(`Could not install extension: ${error?.message ?? error}`);
|
|
35
|
+
}
|
|
36
|
+
addMore = await this.cli.confirm("Add another extension?", false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Installs (or reinstalls) an extension from input and persists it. */
|
|
40
|
+
async addFromInput(input) {
|
|
41
|
+
this.cli.startAction("EXT:INSTALL", "Downloading extension…");
|
|
42
|
+
try {
|
|
43
|
+
const ext = await this.installer.install(input);
|
|
44
|
+
this.persist(this.upsert(this.current(), ext));
|
|
45
|
+
this.cli.endAction("EXT:INSTALL");
|
|
46
|
+
return ext;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.cli.failAction("EXT:INSTALL");
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
list() {
|
|
54
|
+
return this.current();
|
|
55
|
+
}
|
|
56
|
+
/** Removes an installed extension (files + config). Returns false if unknown. */
|
|
57
|
+
async remove(id) {
|
|
58
|
+
const list = this.current();
|
|
59
|
+
const target = list.find((ext) => ext.id === id);
|
|
60
|
+
if (!target) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
this.installer.removeFiles(target.path);
|
|
64
|
+
this.persist(list.filter((ext) => ext.id !== id));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Daily update check across all managed extensions; prompts before each
|
|
69
|
+
* update. Callers must ensure the session is interactive first.
|
|
70
|
+
*/
|
|
71
|
+
async remindIfDue() {
|
|
72
|
+
if (this.current().length === 0 || !this.isCheckDue()) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await this.updateAll();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Checks every managed extension for updates now (ignores the daily gate).
|
|
79
|
+
* Used by `sk extension update`.
|
|
80
|
+
*/
|
|
81
|
+
async updateAll() {
|
|
82
|
+
const list = this.current();
|
|
83
|
+
// stamp so the daily reminder does not immediately ask again
|
|
84
|
+
this.cliConfig.save({ lastExtensionUpdateCheck: this.nowIso() });
|
|
85
|
+
for (const ext of list) {
|
|
86
|
+
await this.remindOne(ext);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async remindOne(ext) {
|
|
90
|
+
let latest;
|
|
91
|
+
try {
|
|
92
|
+
latest = await this.installer.checkLatestVersion(ext.id);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!latest || compareVersions(latest, ext.version) <= 0) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const update = await this.cli.confirm(`Update available for ${ext.name}: ${ext.version} → ${latest}. Install?`, true);
|
|
101
|
+
if (!update) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const updated = await this.addFromInput(ext.id);
|
|
106
|
+
this.cli.writeSuccess(`Updated ${updated.name} to ${updated.version}`);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
this.cli.writeError(`Update failed: ${error?.message ?? error}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
current() {
|
|
113
|
+
return this.userConfig.managedChromeExtensions ?? [];
|
|
114
|
+
}
|
|
115
|
+
upsert(list, ext) {
|
|
116
|
+
return [...list.filter((entry) => entry.id !== ext.id), ext];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Persists the managed list and mirrors the unpacked dirs into
|
|
120
|
+
* chromeExtensionPaths (the list the onboarding browser actually loads),
|
|
121
|
+
* preserving any manually configured paths outside our extensions folder.
|
|
122
|
+
*/
|
|
123
|
+
persist(managed) {
|
|
124
|
+
const managedDir = this.installer.extensionsDir;
|
|
125
|
+
const managedPaths = new Set(managed.map((ext) => ext.path));
|
|
126
|
+
const manualPaths = this.userConfig.chromeExtensionPaths
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((entry) => entry.trim())
|
|
129
|
+
.filter((entry) => entry.length > 0 &&
|
|
130
|
+
!entry.startsWith(managedDir) &&
|
|
131
|
+
!managedPaths.has(entry));
|
|
132
|
+
const combined = [...manualPaths, ...managed.map((ext) => ext.path)];
|
|
133
|
+
this.cliConfig.save({
|
|
134
|
+
managedChromeExtensions: managed,
|
|
135
|
+
chromeExtensionPaths: combined.join(","),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
isCheckDue() {
|
|
139
|
+
const last = this.userConfig.lastExtensionUpdateCheck;
|
|
140
|
+
if (!last) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const lastMs = new Date(last).getTime();
|
|
144
|
+
if (Number.isNaN(lastMs)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return Date.now() - lastMs >= MS_PER_DAY;
|
|
148
|
+
}
|
|
149
|
+
nowIso() {
|
|
150
|
+
return new Date().toISOString();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for dealing with the Chrome Web Store: extracting an extension
|
|
3
|
+
* id from user input, and building the Google update-service URLs used to
|
|
4
|
+
* download a CRX and to check for a newer version.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Resolves a Chrome extension id from a bare id or a Web Store URL. Supports
|
|
8
|
+
* both the current `chromewebstore.google.com/detail/<slug>/<id>` and the
|
|
9
|
+
* legacy `chrome.google.com/webstore/detail/<slug>/<id>` layouts. Returns null
|
|
10
|
+
* when no valid id can be found.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseExtensionId(input: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Builds the download URL for an extension's CRX. `response=redirect` makes the
|
|
15
|
+
* endpoint 302 to the actual CRX file, which fetch follows automatically.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildCrxUrl(id: string, chromeMajorVersion: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Builds the update-check URL. `installsource=ondemand` makes the endpoint
|
|
20
|
+
* report the latest available version (without it, it answers "noupdate"),
|
|
21
|
+
* which we then compare against the installed version ourselves.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildUpdateCheckUrl(id: string, chromeMajorVersion: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parses the latest version from an update-check XML response. Returns null when
|
|
26
|
+
* the response reports no update or carries no version attribute.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseUpdateCheckVersion(xml: string): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Compares two dotted numeric versions (handles Chrome's 4-part versions, which
|
|
31
|
+
* semver cannot parse). Returns >0 if a is newer, <0 if older, 0 if equal.
|
|
32
|
+
*/
|
|
33
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for dealing with the Chrome Web Store: extracting an extension
|
|
3
|
+
* id from user input, and building the Google update-service URLs used to
|
|
4
|
+
* download a CRX and to check for a newer version.
|
|
5
|
+
*/
|
|
6
|
+
const EXTENSION_ID = /[a-p]{32}/;
|
|
7
|
+
const CRX_ENDPOINT = "https://clients2.google.com/service/update2/crx";
|
|
8
|
+
/**
|
|
9
|
+
* Resolves a Chrome extension id from a bare id or a Web Store URL. Supports
|
|
10
|
+
* both the current `chromewebstore.google.com/detail/<slug>/<id>` and the
|
|
11
|
+
* legacy `chrome.google.com/webstore/detail/<slug>/<id>` layouts. Returns null
|
|
12
|
+
* when no valid id can be found.
|
|
13
|
+
*/
|
|
14
|
+
export function parseExtensionId(input) {
|
|
15
|
+
const trimmed = (input ?? "").trim();
|
|
16
|
+
if (!trimmed) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (new RegExp(`^${EXTENSION_ID.source}$`).test(trimmed)) {
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
const detail = trimmed.match(new RegExp(`/detail/(?:[^/]+/)?(${EXTENSION_ID.source})`));
|
|
23
|
+
if (detail) {
|
|
24
|
+
return detail[1];
|
|
25
|
+
}
|
|
26
|
+
const anywhere = trimmed.match(EXTENSION_ID);
|
|
27
|
+
return anywhere ? anywhere[0] : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds the download URL for an extension's CRX. `response=redirect` makes the
|
|
31
|
+
* endpoint 302 to the actual CRX file, which fetch follows automatically.
|
|
32
|
+
*/
|
|
33
|
+
export function buildCrxUrl(id, chromeMajorVersion) {
|
|
34
|
+
const x = encodeURIComponent(`id=${id}&installsource=ondemand&uc`);
|
|
35
|
+
return (`${CRX_ENDPOINT}?response=redirect&acceptformat=crx2,crx3` +
|
|
36
|
+
`&prodversion=${chromeMajorVersion}&x=${x}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds the update-check URL. `installsource=ondemand` makes the endpoint
|
|
40
|
+
* report the latest available version (without it, it answers "noupdate"),
|
|
41
|
+
* which we then compare against the installed version ourselves.
|
|
42
|
+
*/
|
|
43
|
+
export function buildUpdateCheckUrl(id, chromeMajorVersion) {
|
|
44
|
+
const x = encodeURIComponent(`id=${id}&installsource=ondemand&uc`);
|
|
45
|
+
return (`${CRX_ENDPOINT}?response=updatecheck&acceptformat=crx2,crx3` +
|
|
46
|
+
`&prodversion=${chromeMajorVersion}&x=${x}`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parses the latest version from an update-check XML response. Returns null when
|
|
50
|
+
* the response reports no update or carries no version attribute.
|
|
51
|
+
*/
|
|
52
|
+
export function parseUpdateCheckVersion(xml) {
|
|
53
|
+
if (/status="noupdate"/.test(xml)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const match = xml.match(/<updatecheck[^>]*\bversion="([^"]+)"/);
|
|
57
|
+
return match ? match[1] : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Compares two dotted numeric versions (handles Chrome's 4-part versions, which
|
|
61
|
+
* semver cannot parse). Returns >0 if a is newer, <0 if older, 0 if equal.
|
|
62
|
+
*/
|
|
63
|
+
export function compareVersions(a, b) {
|
|
64
|
+
const partsA = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
65
|
+
const partsB = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
66
|
+
const length = Math.max(partsA.length, partsB.length);
|
|
67
|
+
for (let index = 0; index < length; index++) {
|
|
68
|
+
const diff = (partsA[index] || 0) - (partsB[index] || 0);
|
|
69
|
+
if (diff !== 0) {
|
|
70
|
+
return diff > 0 ? 1 : -1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { buildCrxUrl, buildUpdateCheckUrl, compareVersions, parseExtensionId, parseUpdateCheckVersion, } from "./webstore.js";
|
|
4
|
+
const ID = "fmkadmapgofadopljbjfkapdkoienihi";
|
|
5
|
+
describe("webstore.parseExtensionId", () => {
|
|
6
|
+
it("accepts a bare 32-char id", () => {
|
|
7
|
+
expect(parseExtensionId(ID)).to.equal(ID);
|
|
8
|
+
});
|
|
9
|
+
it("extracts the id from a current Web Store URL", () => {
|
|
10
|
+
expect(parseExtensionId(`https://chromewebstore.google.com/detail/react-developer-tools/${ID}`)).to.equal(ID);
|
|
11
|
+
});
|
|
12
|
+
it("extracts the id from a legacy Web Store URL with query", () => {
|
|
13
|
+
expect(parseExtensionId(`https://chrome.google.com/webstore/detail/react/${ID}?hl=en`)).to.equal(ID);
|
|
14
|
+
});
|
|
15
|
+
it("returns null for input without an id", () => {
|
|
16
|
+
expect(parseExtensionId("not-an-extension")).to.equal(null);
|
|
17
|
+
expect(parseExtensionId("")).to.equal(null);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("webstore URL builders", () => {
|
|
21
|
+
it("builds a redirecting CRX download URL with the id and prodversion", () => {
|
|
22
|
+
const url = buildCrxUrl(ID, "131");
|
|
23
|
+
expect(url).to.contain("response=redirect");
|
|
24
|
+
expect(url).to.contain("prodversion=131");
|
|
25
|
+
expect(url).to.contain(encodeURIComponent(`id=${ID}`));
|
|
26
|
+
});
|
|
27
|
+
it("builds an update-check URL that reports the latest version", () => {
|
|
28
|
+
const url = buildUpdateCheckUrl(ID, "131");
|
|
29
|
+
expect(url).to.contain("response=updatecheck");
|
|
30
|
+
expect(url).to.contain(encodeURIComponent("installsource=ondemand"));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("webstore.parseUpdateCheckVersion", () => {
|
|
34
|
+
it("parses the version from an update response", () => {
|
|
35
|
+
const xml = `<gupdate><app appid="${ID}">` +
|
|
36
|
+
`<updatecheck status="ok" codebase="https://x/ext.crx" version="4.28.0"/>` +
|
|
37
|
+
`</app></gupdate>`;
|
|
38
|
+
expect(parseUpdateCheckVersion(xml)).to.equal("4.28.0");
|
|
39
|
+
});
|
|
40
|
+
it("returns null when there is no update", () => {
|
|
41
|
+
const xml = `<updatecheck status="noupdate"/>`;
|
|
42
|
+
expect(parseUpdateCheckVersion(xml)).to.equal(null);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("webstore.compareVersions", () => {
|
|
46
|
+
it("handles 4-part Chrome-style versions", () => {
|
|
47
|
+
expect(compareVersions("1.2.3.4", "1.2.3.3")).to.be.greaterThan(0);
|
|
48
|
+
expect(compareVersions("1.2.0.0", "1.10.0.0")).to.be.lessThan(0);
|
|
49
|
+
expect(compareVersions("2.0", "2.0.0.0")).to.equal(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -6,5 +6,9 @@ export * from "./browser/browser-manager.js";
|
|
|
6
6
|
export * from "./browser/browser-setup-service.js";
|
|
7
7
|
export * from "./difftool/difftool-detector.js";
|
|
8
8
|
export * from "./difftool/difftool-setup-service.js";
|
|
9
|
+
export * from "./extension/webstore.js";
|
|
10
|
+
export * from "./extension/crx.js";
|
|
11
|
+
export * from "./extension/extension-installer.js";
|
|
12
|
+
export * from "./extension/extension-setup-service.js";
|
|
9
13
|
export * from "./os/tool-registry.js";
|
|
10
14
|
export * from "./os/detector-helper.js";
|
|
@@ -6,5 +6,9 @@ export * from "./browser/browser-manager.js";
|
|
|
6
6
|
export * from "./browser/browser-setup-service.js";
|
|
7
7
|
export * from "./difftool/difftool-detector.js";
|
|
8
8
|
export * from "./difftool/difftool-setup-service.js";
|
|
9
|
+
export * from "./extension/webstore.js";
|
|
10
|
+
export * from "./extension/crx.js";
|
|
11
|
+
export * from "./extension/extension-installer.js";
|
|
12
|
+
export * from "./extension/extension-setup-service.js";
|
|
9
13
|
export * from "./os/tool-registry.js";
|
|
10
14
|
export * from "./os/detector-helper.js";
|
|
@@ -6,6 +6,8 @@ import { BrowserManager } from "./browser/browser-manager.js";
|
|
|
6
6
|
import { BrowserSetupService } from "./browser/browser-setup-service.js";
|
|
7
7
|
import { DiffToolDetector } from "./difftool/difftool-detector.js";
|
|
8
8
|
import { DiffToolSetupService } from "./difftool/difftool-setup-service.js";
|
|
9
|
+
import { ExtensionInstaller, resolveChromeMajorVersion, } from "./extension/extension-installer.js";
|
|
10
|
+
import { ExtensionSetupService } from "./extension/extension-setup-service.js";
|
|
9
11
|
import { SetupContext } from "./setup-context.js";
|
|
10
12
|
import { SetupService } from "./setup-service.js";
|
|
11
13
|
export class SetupServiceFactory {
|
|
@@ -31,7 +33,9 @@ export class SetupServiceFactory {
|
|
|
31
33
|
const browserSetup = new BrowserSetupService(cli, cliConfig, userConfig, browserDetector, browserManager);
|
|
32
34
|
const diffDetector = new DiffToolDetector(platform, home);
|
|
33
35
|
const diffSetup = new DiffToolSetupService(cli, cliConfig, userConfig, diffDetector);
|
|
34
|
-
|
|
36
|
+
const extensionInstaller = new ExtensionInstaller(cacheDir, resolveChromeMajorVersion(userConfig.browserBuildId));
|
|
37
|
+
const extensionSetup = new ExtensionSetupService(cli, cliConfig, userConfig, extensionInstaller);
|
|
38
|
+
this.service = new SetupService(cli, cliConfig, userConfig, browserSetup, diffSetup, extensionSetup, this.context);
|
|
35
39
|
return this.service;
|
|
36
40
|
}
|
|
37
41
|
}
|
|
@@ -2,6 +2,7 @@ import { CliServiceInterface } from "../cli/index.js";
|
|
|
2
2
|
import { CliConfig, UserCliConfig } from "../../helpers/cli-config.js";
|
|
3
3
|
import { BrowserSetupService } from "./browser/browser-setup-service.js";
|
|
4
4
|
import { DiffToolSetupService } from "./difftool/difftool-setup-service.js";
|
|
5
|
+
import { ExtensionSetupService } from "./extension/extension-setup-service.js";
|
|
5
6
|
import { SetupContext } from "./setup-context.js";
|
|
6
7
|
export declare class SetupService {
|
|
7
8
|
private readonly cli;
|
|
@@ -9,12 +10,20 @@ export declare class SetupService {
|
|
|
9
10
|
private readonly userConfig;
|
|
10
11
|
private readonly browserSetup;
|
|
11
12
|
private readonly diffSetup;
|
|
13
|
+
private readonly extensionSetup;
|
|
12
14
|
private readonly context;
|
|
13
|
-
constructor(cli: CliServiceInterface, cliConfig: CliConfig, userConfig: UserCliConfig, browserSetup: BrowserSetupService, diffSetup: DiffToolSetupService, context: SetupContext);
|
|
14
|
-
/**
|
|
15
|
+
constructor(cli: CliServiceInterface, cliConfig: CliConfig, userConfig: UserCliConfig, browserSetup: BrowserSetupService, diffSetup: DiffToolSetupService, extensionSetup: ExtensionSetupService, context: SetupContext);
|
|
16
|
+
/**
|
|
17
|
+
* Runs the full first-run wizard (browser + diff tool + extensions) and marks
|
|
18
|
+
* it done.
|
|
19
|
+
*/
|
|
15
20
|
run(): Promise<void>;
|
|
16
21
|
/** True when the first-run wizard has already been completed once. */
|
|
17
22
|
isFirstRunCompleted(): boolean;
|
|
18
23
|
/** Update-reminder check for the browser, used by the init hook. */
|
|
19
24
|
remindBrowserUpdate(): Promise<void>;
|
|
25
|
+
/** Daily update-reminder check for managed extensions, used by the init hook. */
|
|
26
|
+
remindExtensionUpdates(): Promise<void>;
|
|
27
|
+
/** Extension management operations exposed for the `sk extension` commands. */
|
|
28
|
+
get extensions(): ExtensionSetupService;
|
|
20
29
|
}
|
|
@@ -4,24 +4,31 @@ export class SetupService {
|
|
|
4
4
|
userConfig;
|
|
5
5
|
browserSetup;
|
|
6
6
|
diffSetup;
|
|
7
|
+
extensionSetup;
|
|
7
8
|
context;
|
|
8
|
-
constructor(cli, cliConfig, userConfig, browserSetup, diffSetup, context) {
|
|
9
|
+
constructor(cli, cliConfig, userConfig, browserSetup, diffSetup, extensionSetup, context) {
|
|
9
10
|
this.cli = cli;
|
|
10
11
|
this.cliConfig = cliConfig;
|
|
11
12
|
this.userConfig = userConfig;
|
|
12
13
|
this.browserSetup = browserSetup;
|
|
13
14
|
this.diffSetup = diffSetup;
|
|
15
|
+
this.extensionSetup = extensionSetup;
|
|
14
16
|
this.context = context;
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Runs the full first-run wizard (browser + diff tool + extensions) and marks
|
|
20
|
+
* it done.
|
|
21
|
+
*/
|
|
17
22
|
async run() {
|
|
18
23
|
this.cli.spacer();
|
|
19
24
|
this.cli.write(this.cli.style.bold("Speed Kit CLI — setup"));
|
|
20
|
-
this.cli.comment("Configure the browser
|
|
25
|
+
this.cli.comment("Configure the browser, diff tool and extensions used for onboarding.");
|
|
21
26
|
this.cli.spacer();
|
|
22
27
|
await this.browserSetup.run();
|
|
23
28
|
this.cli.spacer();
|
|
24
29
|
await this.diffSetup.run();
|
|
30
|
+
this.cli.spacer();
|
|
31
|
+
await this.extensionSetup.run();
|
|
25
32
|
this.cliConfig.save({ firstRunCompleted: true });
|
|
26
33
|
this.cli.spacer();
|
|
27
34
|
this.cli.writeSuccess("Setup complete. Re-run any time with `sk config setup`.");
|
|
@@ -34,4 +41,12 @@ export class SetupService {
|
|
|
34
41
|
async remindBrowserUpdate() {
|
|
35
42
|
await this.browserSetup.remindIfDue();
|
|
36
43
|
}
|
|
44
|
+
/** Daily update-reminder check for managed extensions, used by the init hook. */
|
|
45
|
+
async remindExtensionUpdates() {
|
|
46
|
+
await this.extensionSetup.remindIfDue();
|
|
47
|
+
}
|
|
48
|
+
/** Extension management operations exposed for the `sk extension` commands. */
|
|
49
|
+
get extensions() {
|
|
50
|
+
return this.extensionSetup;
|
|
51
|
+
}
|
|
37
52
|
}
|
package/oclif.manifest.json
CHANGED
|
@@ -999,7 +999,118 @@
|
|
|
999
999
|
"config",
|
|
1000
1000
|
"setup.js"
|
|
1001
1001
|
]
|
|
1002
|
+
},
|
|
1003
|
+
"extension:add": {
|
|
1004
|
+
"aliases": [],
|
|
1005
|
+
"args": {
|
|
1006
|
+
"ref": {
|
|
1007
|
+
"description": "Chrome Web Store link or extension id",
|
|
1008
|
+
"name": "ref",
|
|
1009
|
+
"required": false
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
"description": "Install a Chrome extension into the onboarding browser by Web Store link or id",
|
|
1013
|
+
"examples": [
|
|
1014
|
+
"$ sk extension add fmkadmapgofadopljbjfkapdkoienihi",
|
|
1015
|
+
"$ sk extension add https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi",
|
|
1016
|
+
"$ sk extension add"
|
|
1017
|
+
],
|
|
1018
|
+
"flags": {},
|
|
1019
|
+
"hasDynamicHelp": false,
|
|
1020
|
+
"hiddenAliases": [],
|
|
1021
|
+
"id": "extension:add",
|
|
1022
|
+
"pluginAlias": "@speedkit/cli",
|
|
1023
|
+
"pluginName": "@speedkit/cli",
|
|
1024
|
+
"pluginType": "core",
|
|
1025
|
+
"strict": true,
|
|
1026
|
+
"enableJsonFlag": false,
|
|
1027
|
+
"isESM": true,
|
|
1028
|
+
"relativePath": [
|
|
1029
|
+
"dist",
|
|
1030
|
+
"commands",
|
|
1031
|
+
"extension",
|
|
1032
|
+
"add.js"
|
|
1033
|
+
]
|
|
1034
|
+
},
|
|
1035
|
+
"extension:list": {
|
|
1036
|
+
"aliases": [],
|
|
1037
|
+
"args": {},
|
|
1038
|
+
"description": "List Chrome extensions managed by the setup wizard",
|
|
1039
|
+
"examples": [
|
|
1040
|
+
"$ sk extension list"
|
|
1041
|
+
],
|
|
1042
|
+
"flags": {},
|
|
1043
|
+
"hasDynamicHelp": false,
|
|
1044
|
+
"hiddenAliases": [],
|
|
1045
|
+
"id": "extension:list",
|
|
1046
|
+
"pluginAlias": "@speedkit/cli",
|
|
1047
|
+
"pluginName": "@speedkit/cli",
|
|
1048
|
+
"pluginType": "core",
|
|
1049
|
+
"strict": true,
|
|
1050
|
+
"enableJsonFlag": false,
|
|
1051
|
+
"isESM": true,
|
|
1052
|
+
"relativePath": [
|
|
1053
|
+
"dist",
|
|
1054
|
+
"commands",
|
|
1055
|
+
"extension",
|
|
1056
|
+
"list.js"
|
|
1057
|
+
]
|
|
1058
|
+
},
|
|
1059
|
+
"extension:remove": {
|
|
1060
|
+
"aliases": [],
|
|
1061
|
+
"args": {
|
|
1062
|
+
"ref": {
|
|
1063
|
+
"description": "Chrome Web Store link or extension id",
|
|
1064
|
+
"name": "ref",
|
|
1065
|
+
"required": false
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
"description": "Remove a managed Chrome extension",
|
|
1069
|
+
"examples": [
|
|
1070
|
+
"$ sk extension remove",
|
|
1071
|
+
"$ sk extension remove <id>"
|
|
1072
|
+
],
|
|
1073
|
+
"flags": {},
|
|
1074
|
+
"hasDynamicHelp": false,
|
|
1075
|
+
"hiddenAliases": [],
|
|
1076
|
+
"id": "extension:remove",
|
|
1077
|
+
"pluginAlias": "@speedkit/cli",
|
|
1078
|
+
"pluginName": "@speedkit/cli",
|
|
1079
|
+
"pluginType": "core",
|
|
1080
|
+
"strict": true,
|
|
1081
|
+
"enableJsonFlag": false,
|
|
1082
|
+
"isESM": true,
|
|
1083
|
+
"relativePath": [
|
|
1084
|
+
"dist",
|
|
1085
|
+
"commands",
|
|
1086
|
+
"extension",
|
|
1087
|
+
"remove.js"
|
|
1088
|
+
]
|
|
1089
|
+
},
|
|
1090
|
+
"extension:update": {
|
|
1091
|
+
"aliases": [],
|
|
1092
|
+
"args": {},
|
|
1093
|
+
"description": "Check managed Chrome extensions for updates and install them",
|
|
1094
|
+
"examples": [
|
|
1095
|
+
"$ sk extension update"
|
|
1096
|
+
],
|
|
1097
|
+
"flags": {},
|
|
1098
|
+
"hasDynamicHelp": false,
|
|
1099
|
+
"hiddenAliases": [],
|
|
1100
|
+
"id": "extension:update",
|
|
1101
|
+
"pluginAlias": "@speedkit/cli",
|
|
1102
|
+
"pluginName": "@speedkit/cli",
|
|
1103
|
+
"pluginType": "core",
|
|
1104
|
+
"strict": true,
|
|
1105
|
+
"enableJsonFlag": false,
|
|
1106
|
+
"isESM": true,
|
|
1107
|
+
"relativePath": [
|
|
1108
|
+
"dist",
|
|
1109
|
+
"commands",
|
|
1110
|
+
"extension",
|
|
1111
|
+
"update.js"
|
|
1112
|
+
]
|
|
1002
1113
|
}
|
|
1003
1114
|
},
|
|
1004
|
-
"version": "4.
|
|
1115
|
+
"version": "4.21.0"
|
|
1005
1116
|
}
|