@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +83 -2
  3. package/dist/commands/extension/add.d.ts +9 -0
  4. package/dist/commands/extension/add.js +41 -0
  5. package/dist/commands/extension/list.d.ts +6 -0
  6. package/dist/commands/extension/list.js +27 -0
  7. package/dist/commands/extension/remove.d.ts +9 -0
  8. package/dist/commands/extension/remove.js +38 -0
  9. package/dist/commands/extension/update.d.ts +6 -0
  10. package/dist/commands/extension/update.js +20 -0
  11. package/dist/commands/generate-customer-config.d.ts +2 -0
  12. package/dist/commands/generate-customer-config.js +10 -2
  13. package/dist/helpers/cli-config.d.ts +11 -0
  14. package/dist/helpers/cli-config.js +4 -0
  15. package/dist/helpers/cli-config.spec.js +13 -0
  16. package/dist/hooks/init/first-run.js +1 -0
  17. package/dist/services/customer-config/customer-config-service-context.d.ts +9 -1
  18. package/dist/services/customer-config/customer-config-service-context.js +11 -1
  19. package/dist/services/customer-config/customer-config-service-model.d.ts +6 -0
  20. package/dist/services/customer-config/customer-config-service-model.js +36 -0
  21. package/dist/services/customer-config/customer-config-service.d.ts +8 -3
  22. package/dist/services/customer-config/customer-config-service.js +71 -25
  23. package/dist/services/customer-config/template-source.d.ts +33 -0
  24. package/dist/services/customer-config/template-source.js +49 -0
  25. package/dist/services/integration-api/integration-api-model.js +3 -1
  26. package/dist/services/setup/extension/crx.d.ts +13 -0
  27. package/dist/services/setup/extension/crx.js +48 -0
  28. package/dist/services/setup/extension/crx.spec.d.ts +1 -0
  29. package/dist/services/setup/extension/crx.spec.js +38 -0
  30. package/dist/services/setup/extension/extension-installer.d.ts +31 -0
  31. package/dist/services/setup/extension/extension-installer.js +87 -0
  32. package/dist/services/setup/extension/extension-setup-service.d.ts +40 -0
  33. package/dist/services/setup/extension/extension-setup-service.js +152 -0
  34. package/dist/services/setup/extension/webstore.d.ts +33 -0
  35. package/dist/services/setup/extension/webstore.js +74 -0
  36. package/dist/services/setup/extension/webstore.spec.d.ts +1 -0
  37. package/dist/services/setup/extension/webstore.spec.js +51 -0
  38. package/dist/services/setup/index.d.ts +4 -0
  39. package/dist/services/setup/index.js +4 -0
  40. package/dist/services/setup/setup-service-factory.js +5 -1
  41. package/dist/services/setup/setup-service.d.ts +11 -2
  42. package/dist/services/setup/setup-service.js +18 -3
  43. package/oclif.manifest.json +131 -1
  44. package/package.json +8 -1
  45. package/dist/services/customer-config/templates/config_SpeedKit.js.hbs +0 -548
  46. package/dist/services/customer-config/templates/config_customer.json.hbs +0 -38
  47. package/dist/services/customer-config/templates/config_documentHandler.js.hbs +0 -362
  48. package/dist/services/customer-config/templates/config_dynamicBlocks.es6.hbs +0 -163
  49. package/dist/services/customer-config/templates/config_dynamicStyles.css.hbs +0 -137
  50. package/dist/services/customer-config/templates/config_loadHandler.js.hbs +0 -391
  51. 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 { fileURLToPath } from "node:url";
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
- templates = [
26
- "config_customer.json.hbs",
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
- getHandlebarsOutputFileName(fileName) {
43
- return fileName.replace(".hbs", "");
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 compileHandlebarsFiles(configSettings) {
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 this.templates) {
95
- const outputFileName = this.getHandlebarsOutputFileName(templateFile);
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
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
- if (configSettings.addDowntimeDetection) {
424
- this.templates.push("downtimeDetection.js.hbs");
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
- export const DEFAULT_BLOCKED_FILES = ["page-examples"];
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
+ }