@speedkit/cli 4.20.5 → 4.22.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 +22 -0
- package/README.md +83 -2
- 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/commands/generate-customer-config.d.ts +2 -0
- package/dist/commands/generate-customer-config.js +10 -2
- 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/customer-config/customer-config-service-context.d.ts +9 -1
- package/dist/services/customer-config/customer-config-service-context.js +11 -1
- package/dist/services/customer-config/customer-config-service-model.d.ts +6 -0
- package/dist/services/customer-config/customer-config-service-model.js +36 -0
- package/dist/services/customer-config/customer-config-service.d.ts +8 -3
- package/dist/services/customer-config/customer-config-service.js +71 -25
- package/dist/services/customer-config/template-source.d.ts +33 -0
- package/dist/services/customer-config/template-source.js +49 -0
- package/dist/services/integration-api/integration-api-model.js +3 -1
- 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 +131 -1
- package/package.json +8 -1
- package/dist/services/customer-config/templates/config_SpeedKit.js.hbs +0 -548
- package/dist/services/customer-config/templates/config_customer.json.hbs +0 -38
- package/dist/services/customer-config/templates/config_documentHandler.js.hbs +0 -362
- package/dist/services/customer-config/templates/config_dynamicBlocks.es6.hbs +0 -163
- package/dist/services/customer-config/templates/config_dynamicStyles.css.hbs +0 -137
- package/dist/services/customer-config/templates/config_loadHandler.js.hbs +0 -391
- package/dist/services/customer-config/templates/downtimeDetection.js.hbs +0 -209
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import Handlebars from "handlebars";
|
|
4
3
|
import { analyzeSite, } from "../../helpers/site-analyzer.js";
|
|
5
|
-
import { DirectoryOverwriteOption, EnvironmentType, ShopSystem, } from "./customer-config-service-model.js";
|
|
6
|
-
import {
|
|
4
|
+
import { DirectoryOverwriteOption, EnvironmentType, ShopSystem, TEMPLATE_VARIABLES, } from "./customer-config-service-model.js";
|
|
5
|
+
import { findUnknownVariables, resolveTemplateSource, } from "./template-source.js";
|
|
7
6
|
export class CustomerConfigService {
|
|
8
7
|
context;
|
|
9
8
|
cli;
|
|
@@ -22,14 +21,8 @@ export class CustomerConfigService {
|
|
|
22
21
|
get outputDirPath() {
|
|
23
22
|
return this._outputDirPath;
|
|
24
23
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"config_documentHandler.js.hbs",
|
|
28
|
-
"config_dynamicBlocks.es6.hbs",
|
|
29
|
-
"config_dynamicStyles.css.hbs",
|
|
30
|
-
"config_loadHandler.js.hbs",
|
|
31
|
-
"config_SpeedKit.js.hbs",
|
|
32
|
-
];
|
|
24
|
+
/** Set from the "Downtime Detection" feature answer in the wizard. */
|
|
25
|
+
includeDowntimeDetection = false;
|
|
33
26
|
constructor(context, cli) {
|
|
34
27
|
this.context = context;
|
|
35
28
|
const { appName } = this.context;
|
|
@@ -39,8 +32,8 @@ export class CustomerConfigService {
|
|
|
39
32
|
getOutputDirPath(directoryName = "") {
|
|
40
33
|
return path.join(process.cwd(), this.context.customersDirectory, directoryName);
|
|
41
34
|
}
|
|
42
|
-
|
|
43
|
-
return fileName.replace(
|
|
35
|
+
getOutputFileName(fileName) {
|
|
36
|
+
return fileName.replace(/\.njk$/, "");
|
|
44
37
|
}
|
|
45
38
|
getPathWithChangedDir(directoryPath, newDirectory) {
|
|
46
39
|
return path.join(path.dirname(directoryPath), newDirectory);
|
|
@@ -65,7 +58,41 @@ export class CustomerConfigService {
|
|
|
65
58
|
});
|
|
66
59
|
return this.selectOutputDirectory(this.getPathWithChangedDir(directoryPath, newDirectoryName));
|
|
67
60
|
}
|
|
61
|
+
/** A pre-PoC config marks itself via splitTestId "pre_poc" in config_SpeedKit.js. */
|
|
62
|
+
isPrePocConfig(directoryPath) {
|
|
63
|
+
const speedKitConfigPath = path.join(directoryPath, "config_SpeedKit.js");
|
|
64
|
+
if (!fs.existsSync(speedKitConfigPath)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return /["']pre_poc["']/.test(fs.readFileSync(speedKitConfigPath, "utf-8"));
|
|
68
|
+
}
|
|
69
|
+
/** Parks every config_* file as *_pre-poc (suffix before the extension). */
|
|
70
|
+
renamePrePocFiles(directoryPath) {
|
|
71
|
+
const configFilePattern = /^(config_[^.]+)\.(.+)$/;
|
|
72
|
+
for (const fileName of fs.readdirSync(directoryPath)) {
|
|
73
|
+
const match = fileName.match(configFilePattern);
|
|
74
|
+
if (!match) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
fs.renameSync(path.join(directoryPath, fileName), path.join(directoryPath, `${match[1]}_pre-poc.${match[2]}`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
async selectOutputDirectory(directoryPath) {
|
|
81
|
+
// Graduation: a full-config run over an existing pre-PoC config parks the
|
|
82
|
+
// pre-PoC files as *_pre-poc so both versions can be merged afterwards.
|
|
83
|
+
if (this.context.mode !== "pre-poc" && this.isPrePocConfig(directoryPath)) {
|
|
84
|
+
this.cli.spacer();
|
|
85
|
+
this.cli.write(`${directoryPath} holds a pre-PoC config (splitTestId "pre_poc").\n` +
|
|
86
|
+
"On continue, its config_* files are renamed to *_pre-poc and the full " +
|
|
87
|
+
"config is scaffolded next to them. Merge the verified trackers into the " +
|
|
88
|
+
"new config_loadHandler.js afterwards, then delete the *_pre-poc files.\n");
|
|
89
|
+
const graduate = await this.cli.confirm("Rename the pre-PoC files and continue?");
|
|
90
|
+
if (graduate) {
|
|
91
|
+
this.renamePrePocFiles(directoryPath);
|
|
92
|
+
return directoryPath;
|
|
93
|
+
}
|
|
94
|
+
// declined → fall through to the regular not-empty protection
|
|
95
|
+
}
|
|
69
96
|
// protect output directory if already existing and not empty:
|
|
70
97
|
if (this.isDirNotEmpty(directoryPath)) {
|
|
71
98
|
this.cli.spacer();
|
|
@@ -86,13 +113,22 @@ export class CustomerConfigService {
|
|
|
86
113
|
}
|
|
87
114
|
return directoryPath;
|
|
88
115
|
}
|
|
89
|
-
async
|
|
116
|
+
async compileTemplateFiles(configSettings) {
|
|
117
|
+
const source = await resolveTemplateSource(this.context.templatesPath, this.context.mode);
|
|
118
|
+
this.cli.write(`Templates: ${source.origin}\n`);
|
|
119
|
+
// Contract check: the templates may only use variables this CLI knows.
|
|
120
|
+
const unknown = findUnknownVariables(source.manifest, TEMPLATE_VARIABLES);
|
|
121
|
+
if (unknown.length > 0) {
|
|
122
|
+
throw new Error(`The templates use variables this CLI does not know: ${unknown.join(", ")}. ` +
|
|
123
|
+
"Your sk CLI is older than the templates — update it (npm i) and retry.");
|
|
124
|
+
}
|
|
90
125
|
// overwriting protection for output directory:
|
|
91
126
|
this.outputDirPath = await this.selectOutputDirectory(this.outputDirPath);
|
|
92
127
|
this.cli.spacer();
|
|
128
|
+
const templateFiles = source.manifest.files.filter((file) => file !== "downtimeDetection.js.njk" || this.includeDowntimeDetection);
|
|
93
129
|
// write files:
|
|
94
|
-
for (const templateFile of
|
|
95
|
-
const outputFileName = this.
|
|
130
|
+
for (const templateFile of templateFiles) {
|
|
131
|
+
const outputFileName = this.getOutputFileName(templateFile);
|
|
96
132
|
this.cli.startAction(`CREATE:CUSTOMER:CONFIG:${outputFileName}`, `Adding ${outputFileName}`);
|
|
97
133
|
try {
|
|
98
134
|
const outputFilePath = path.join(this.outputDirPath, outputFileName);
|
|
@@ -100,11 +136,7 @@ export class CustomerConfigService {
|
|
|
100
136
|
this.cli.endAction(`CREATE:CUSTOMER:CONFIG:${outputFileName}`, "skipped!");
|
|
101
137
|
continue;
|
|
102
138
|
}
|
|
103
|
-
const
|
|
104
|
-
const filePath = path.resolve(__dirname, "templates", templateFile);
|
|
105
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
106
|
-
const template = Handlebars.compile(content);
|
|
107
|
-
const result = template(configSettings);
|
|
139
|
+
const result = await source.render(templateFile, configSettings);
|
|
108
140
|
fs.mkdirSync(path.dirname(outputFilePath), { recursive: true });
|
|
109
141
|
fs.writeFileSync(outputFilePath, result);
|
|
110
142
|
this.cli.successAction(`CREATE:CUSTOMER:CONFIG:${outputFileName}`);
|
|
@@ -238,6 +270,22 @@ export class CustomerConfigService {
|
|
|
238
270
|
}
|
|
239
271
|
this.cli.spacer();
|
|
240
272
|
}
|
|
273
|
+
// Pre-PoC: the tracking-only set uses no acceleration answers — stop asking here.
|
|
274
|
+
if (this.context.mode === "pre-poc") {
|
|
275
|
+
return {
|
|
276
|
+
appName: this.appName,
|
|
277
|
+
production,
|
|
278
|
+
staging,
|
|
279
|
+
shopSystemName: selectedShopSystem === ShopSystem.OTHER
|
|
280
|
+
? undefined
|
|
281
|
+
: selectedShopSystem,
|
|
282
|
+
isShopify,
|
|
283
|
+
isShopware,
|
|
284
|
+
isSalesforce,
|
|
285
|
+
isOxid,
|
|
286
|
+
isPlentymarkets,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
241
289
|
const resolve = (feature, fallback) => {
|
|
242
290
|
if (!siteAnalysis)
|
|
243
291
|
return { label: "", checked: fallback };
|
|
@@ -420,10 +468,8 @@ export class CustomerConfigService {
|
|
|
420
468
|
async generateConfig() {
|
|
421
469
|
this.cli.write("\nLet's generate a config...\n");
|
|
422
470
|
const configSettings = await this.getConfigSettings();
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
await this.compileHandlebarsFiles(configSettings);
|
|
471
|
+
this.includeDowntimeDetection = Boolean(configSettings.addDowntimeDetection);
|
|
472
|
+
await this.compileTemplateFiles(configSettings);
|
|
427
473
|
await this.logWarnings(configSettings);
|
|
428
474
|
this.cli.writeSuccess("\nConfig generation completed 👏\n");
|
|
429
475
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The templates/<set>/manifest.json contract in sk-onboarding. */
|
|
2
|
+
export interface TemplateManifest {
|
|
3
|
+
engine: string;
|
|
4
|
+
engineOptions: Record<string, boolean>;
|
|
5
|
+
files: string[];
|
|
6
|
+
partials?: string[];
|
|
7
|
+
variables: Record<string, {
|
|
8
|
+
type: "boolean" | "string" | "string?";
|
|
9
|
+
sample?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export interface TemplateSource {
|
|
13
|
+
/** Where the templates come from — shown to the user. */
|
|
14
|
+
origin: string;
|
|
15
|
+
manifest: TemplateManifest;
|
|
16
|
+
/** Renders one of manifest.files with the wizard settings ({% include %} resolves). */
|
|
17
|
+
render(file: string, settings: Record<string, unknown>): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolves where the wizard templates come from, in order:
|
|
21
|
+
* 1. an explicit --templates path (the templates/ root),
|
|
22
|
+
* 2. the sk-onboarding checkout the CLI runs in (walking up from cwd).
|
|
23
|
+
*
|
|
24
|
+
* `set` picks the template set (wizard mode), e.g. "customer-config" or "pre-poc".
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveTemplateSource(explicitPath?: string, set?: string): Promise<TemplateSource>;
|
|
27
|
+
/**
|
|
28
|
+
* The templates may only use variables this CLI knows — conditional wizard
|
|
29
|
+
* answers are legitimately absent at runtime, so the contract is checked
|
|
30
|
+
* against the CLI's variable inventory, not one run's settings object.
|
|
31
|
+
* A non-empty result means this CLI is older than the templates.
|
|
32
|
+
*/
|
|
33
|
+
export declare function findUnknownVariables(manifest: TemplateManifest, knownVariables: readonly string[]): string[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import nunjucks from "nunjucks";
|
|
4
|
+
const TEMPLATES_DIR = "templates";
|
|
5
|
+
/**
|
|
6
|
+
* Resolves where the wizard templates come from, in order:
|
|
7
|
+
* 1. an explicit --templates path (the templates/ root),
|
|
8
|
+
* 2. the sk-onboarding checkout the CLI runs in (walking up from cwd).
|
|
9
|
+
*
|
|
10
|
+
* `set` picks the template set (wizard mode), e.g. "customer-config" or "pre-poc".
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveTemplateSource(explicitPath, set = "customer-config") {
|
|
13
|
+
if (explicitPath)
|
|
14
|
+
return localSource(path.resolve(explicitPath), set);
|
|
15
|
+
for (let dir = process.cwd();; dir = path.dirname(dir)) {
|
|
16
|
+
const candidate = path.join(dir, TEMPLATES_DIR);
|
|
17
|
+
if (fs.existsSync(path.join(candidate, set, "manifest.json"))) {
|
|
18
|
+
return localSource(candidate, set);
|
|
19
|
+
}
|
|
20
|
+
if (dir === path.dirname(dir))
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
throw new Error("No wizard templates found. Run inside an sk-onboarding checkout or " +
|
|
24
|
+
"pass --templates <path>.");
|
|
25
|
+
}
|
|
26
|
+
function buildEnvironment(loader, manifest) {
|
|
27
|
+
return new nunjucks.Environment(loader, manifest.engineOptions);
|
|
28
|
+
}
|
|
29
|
+
function localSource(templatesRoot, set) {
|
|
30
|
+
const setDir = path.join(templatesRoot, set);
|
|
31
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(setDir, "manifest.json"), "utf8"));
|
|
32
|
+
const env = buildEnvironment(new nunjucks.FileSystemLoader(templatesRoot), manifest);
|
|
33
|
+
const origin = setDir;
|
|
34
|
+
return {
|
|
35
|
+
origin,
|
|
36
|
+
manifest,
|
|
37
|
+
render: async (file, settings) => env.renderString(fs.readFileSync(path.join(setDir, file), "utf8"), settings),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The templates may only use variables this CLI knows — conditional wizard
|
|
42
|
+
* answers are legitimately absent at runtime, so the contract is checked
|
|
43
|
+
* against the CLI's variable inventory, not one run's settings object.
|
|
44
|
+
* A non-empty result means this CLI is older than the templates.
|
|
45
|
+
*/
|
|
46
|
+
export function findUnknownVariables(manifest, knownVariables) {
|
|
47
|
+
const known = new Set(knownVariables);
|
|
48
|
+
return Object.keys(manifest.variables).filter((name) => !known.has(name));
|
|
49
|
+
}
|
|
@@ -26,7 +26,9 @@ export const DEFAULT_BLOCKED_FOLDERS = [
|
|
|
26
26
|
".idea",
|
|
27
27
|
"page-examples",
|
|
28
28
|
];
|
|
29
|
-
|
|
29
|
+
// "_pre-poc": parked pre-PoC files created by the graduation flow of
|
|
30
|
+
// generate-customer-config — kept for the manual merge, never deployed.
|
|
31
|
+
export const DEFAULT_BLOCKED_FILES = ["page-examples", "_pre-poc"];
|
|
30
32
|
export class IntegrationApiContext {
|
|
31
33
|
basePath;
|
|
32
34
|
configName;
|
|
@@ -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
|
+
}
|