@speedkit/cli 4.17.0 → 4.18.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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +75 -1
  3. package/dist/commands/browser/clean.d.ts +9 -0
  4. package/dist/commands/browser/clean.js +43 -0
  5. package/dist/commands/browser/list.d.ts +6 -0
  6. package/dist/commands/browser/list.js +29 -0
  7. package/dist/commands/browser/update.d.ts +6 -0
  8. package/dist/commands/browser/update.js +32 -0
  9. package/dist/commands/config/setup.d.ts +10 -0
  10. package/dist/commands/config/setup.js +28 -0
  11. package/dist/helpers/cli-config.d.ts +19 -0
  12. package/dist/helpers/cli-config.js +70 -4
  13. package/dist/helpers/cli-config.spec.d.ts +1 -0
  14. package/dist/helpers/cli-config.spec.js +70 -0
  15. package/dist/helpers/environment.d.ts +15 -0
  16. package/dist/helpers/environment.js +40 -0
  17. package/dist/helpers/environment.spec.d.ts +1 -0
  18. package/dist/helpers/environment.spec.js +28 -0
  19. package/dist/hooks/init/first-run.d.ts +8 -0
  20. package/dist/hooks/init/first-run.js +36 -0
  21. package/dist/services/onboarding/browser/baqend-response.d.ts +0 -5
  22. package/dist/services/onboarding/browser/baqend-response.js +0 -9
  23. package/dist/services/onboarding/file-events/file-watcher.d.ts +3 -3
  24. package/dist/services/onboarding/file-events/file-watcher.js +11 -7
  25. package/dist/services/onboarding/virtual-orestes-app/index.d.ts +7 -7
  26. package/dist/services/onboarding/virtual-orestes-app/index.js +13 -14
  27. package/dist/services/setup/browser/browser-detector.d.ts +27 -0
  28. package/dist/services/setup/browser/browser-detector.js +47 -0
  29. package/dist/services/setup/browser/browser-manager.d.ts +43 -0
  30. package/dist/services/setup/browser/browser-manager.js +101 -0
  31. package/dist/services/setup/browser/browser-setup-service.d.ts +28 -0
  32. package/dist/services/setup/browser/browser-setup-service.js +136 -0
  33. package/dist/services/setup/difftool/difftool-detector.d.ts +14 -0
  34. package/dist/services/setup/difftool/difftool-detector.js +27 -0
  35. package/dist/services/setup/difftool/difftool-setup-service.d.ts +15 -0
  36. package/dist/services/setup/difftool/difftool-setup-service.js +46 -0
  37. package/dist/services/setup/index.d.ts +10 -0
  38. package/dist/services/setup/index.js +10 -0
  39. package/dist/services/setup/os/detector-helper.d.ts +29 -0
  40. package/dist/services/setup/os/detector-helper.js +65 -0
  41. package/dist/services/setup/os/tool-registry.d.ts +38 -0
  42. package/dist/services/setup/os/tool-registry.js +194 -0
  43. package/dist/services/setup/os/tool-registry.spec.d.ts +1 -0
  44. package/dist/services/setup/os/tool-registry.spec.js +26 -0
  45. package/dist/services/setup/setup-context.d.ts +14 -0
  46. package/dist/services/setup/setup-context.js +15 -0
  47. package/dist/services/setup/setup-service-factory.d.ts +10 -0
  48. package/dist/services/setup/setup-service-factory.js +37 -0
  49. package/dist/services/setup/setup-service.d.ts +20 -0
  50. package/dist/services/setup/setup-service.js +37 -0
  51. package/oclif.manifest.json +122 -1
  52. package/package.json +1 -1
@@ -7,11 +7,6 @@ export declare class BaqendResponse implements Partial<Protocol.Fetch.FulfillReq
7
7
  private readonly contentType;
8
8
  private readonly contentLength;
9
9
  constructor(body: string | Buffer, contentType?: string, headers?: Protocol.Fetch.HeaderEntry[], status?: number);
10
- /**
11
- * Epoch ms parsed from the `last-modified` header (set when the response was
12
- * built), or 0 if absent/unparseable. Used to order cache entries by recency.
13
- */
14
- getLastModified(): number;
15
10
  setHeader(name: string, value: string): void;
16
11
  protected addCustomHeaders(headers?: Protocol.Fetch.HeaderEntry[]): Protocol.Fetch.HeaderEntry[];
17
12
  }
@@ -23,15 +23,6 @@ export class BaqendResponse {
23
23
  ? body.toString("base64")
24
24
  : Buffer.from(body).toString("base64");
25
25
  }
26
- /**
27
- * Epoch ms parsed from the `last-modified` header (set when the response was
28
- * built), or 0 if absent/unparseable. Used to order cache entries by recency.
29
- */
30
- getLastModified() {
31
- const header = this.responseHeaders.find((h) => h.name.toLowerCase() === "last-modified");
32
- const timestamp = header ? Date.parse(header.value) : NaN;
33
- return Number.isNaN(timestamp) ? 0 : timestamp;
34
- }
35
26
  setHeader(name, value) {
36
27
  for (const header of this.responseHeaders) {
37
28
  if (header.name === name) {
@@ -20,10 +20,10 @@ export declare class FileWatcher {
20
20
  setCacheRefresher(cacheRefresher: VirtualOrestesApp): void;
21
21
  /**
22
22
  * Drop the dh-cache after a config change. When a cache refresher is wired
23
- * (local mode) the most recent entries are re-warmed in the background so the
24
- * developer's current pages stay instant and fresh; otherwise the cache is
25
- * simply cleared.
23
+ * (local mode) the currently open page is re-warmed in the background so it
24
+ * stays instant and fresh; otherwise the cache is simply cleared.
26
25
  *
26
+ * @param page the browser page whose current url should be re-warmed
27
27
  * @private
28
28
  */
29
29
  private clearOrRefreshCache;
@@ -28,16 +28,20 @@ export class FileWatcher {
28
28
  }
29
29
  /**
30
30
  * Drop the dh-cache after a config change. When a cache refresher is wired
31
- * (local mode) the most recent entries are re-warmed in the background so the
32
- * developer's current pages stay instant and fresh; otherwise the cache is
33
- * simply cleared.
31
+ * (local mode) the currently open page is re-warmed in the background so it
32
+ * stays instant and fresh; otherwise the cache is simply cleared.
34
33
  *
34
+ * @param page the browser page whose current url should be re-warmed
35
35
  * @private
36
36
  */
37
- clearOrRefreshCache() {
37
+ clearOrRefreshCache(page) {
38
38
  if (this.cacheRefresher) {
39
+ // page.url() carries a hash fragment that never reaches the server, so it
40
+ // is not part of the cache key — strip it before matching.
41
+ const currentUrl = new URL(page.url());
42
+ currentUrl.hash = "";
39
43
  // fire-and-forget: refresh clears the cache first, then re-warms
40
- void this.cacheRefresher.refreshRecent(10);
44
+ void this.cacheRefresher.refreshCurrentUrl(currentUrl.href);
41
45
  return;
42
46
  }
43
47
  this.cache.clear();
@@ -179,7 +183,7 @@ export class FileWatcher {
179
183
  this.cli.writeWarning(`changed file: ${file.name}`);
180
184
  await this.safeClearPageBrowsersCacheStorage(page);
181
185
  await this.documentHandler.buildDocumentHandler();
182
- this.clearOrRefreshCache();
186
+ this.clearOrRefreshCache(page);
183
187
  await installResource.buildContent();
184
188
  }
185
189
  /**
@@ -192,7 +196,7 @@ export class FileWatcher {
192
196
  async onChangeDynamicStyleCallback(file, page) {
193
197
  this.cli.writeWarning(`changed file: ${file.name}`);
194
198
  await this.documentHandler.buildDocumentHandler();
195
- this.clearOrRefreshCache();
199
+ this.clearOrRefreshCache(page);
196
200
  const escapedStyles = encodeURI(file.getContent());
197
201
  const evaluateResult = await safe(race(Promise.all([
198
202
  page.evaluate(this.updateStylesInlineFunction, escapedStyles),
@@ -28,15 +28,15 @@ export declare class VirtualOrestesApp {
28
28
  */
29
29
  refreshUrl(originUrl: string): Promise<void>;
30
30
  /**
31
- * Clear the whole dh-cache, then re-warm the most recently cached entries in
32
- * the background (ordered by their `last-modified` header). Used as a
33
- * replacement for a bare cache clear on config changes: stale entries are
34
- * dropped, but the last `limit` pages are rebuilt proactively so the
35
- * developer's current pages stay instant and fresh.
31
+ * Clear the whole dh-cache, then re-warm only the currently open page's
32
+ * cached entries in the background. Used as a replacement for a bare cache
33
+ * clear on config changes: every stale entry is dropped, but the page the
34
+ * developer is currently looking at is rebuilt proactively so it stays
35
+ * instant and fresh after the change.
36
36
  *
37
- * @param limit how many of the most recently cached entries to re-warm
37
+ * @param currentUrl the url currently open in the browser (page.url())
38
38
  */
39
- refreshRecent(limit?: number): Promise<void>;
39
+ refreshCurrentUrl(currentUrl: string): Promise<void>;
40
40
  /**
41
41
  * Rebuild a single cache entry (by its full asset request url), replacing the
42
42
  * previously cached response. On failure the stale entry is dropped so we
@@ -58,25 +58,24 @@ export class VirtualOrestesApp {
58
58
  }
59
59
  }
60
60
  /**
61
- * Clear the whole dh-cache, then re-warm the most recently cached entries in
62
- * the background (ordered by their `last-modified` header). Used as a
63
- * replacement for a bare cache clear on config changes: stale entries are
64
- * dropped, but the last `limit` pages are rebuilt proactively so the
65
- * developer's current pages stay instant and fresh.
61
+ * Clear the whole dh-cache, then re-warm only the currently open page's
62
+ * cached entries in the background. Used as a replacement for a bare cache
63
+ * clear on config changes: every stale entry is dropped, but the page the
64
+ * developer is currently looking at is rebuilt proactively so it stays
65
+ * instant and fresh after the change.
66
66
  *
67
- * @param limit how many of the most recently cached entries to re-warm
67
+ * @param currentUrl the url currently open in the browser (page.url())
68
68
  */
69
- async refreshRecent(limit = 10) {
70
- const recentKeys = [...this.cache.getAll()]
71
- .sort(([, a], [, b]) => b.getLastModified() - a.getLastModified())
72
- .slice(0, limit)
73
- .map(([key]) => key);
69
+ async refreshCurrentUrl(currentUrl) {
70
+ const matchingKeys = [...this.cache.getAll()]
71
+ .map(([key]) => key)
72
+ .filter((key) => key.includes(currentUrl));
74
73
  this.cache.clear();
75
- if (recentKeys.length === 0) {
74
+ if (matchingKeys.length === 0) {
76
75
  return;
77
76
  }
78
- this.cli.write(`refreshing ${recentKeys.length} recent dh-cache entries in background`);
79
- for (const key of recentKeys) {
77
+ this.cli.write(`refreshing ${matchingKeys.length} dh-cache entries for current url ${currentUrl} in background`);
78
+ for (const key of matchingKeys) {
80
79
  await this.refreshEntry(key);
81
80
  }
82
81
  }
@@ -0,0 +1,27 @@
1
+ import { Platform } from "../os/tool-registry.js";
2
+ export type BrowserSource = "system" | "puppeteer";
3
+ export interface BrowserOption {
4
+ /** stable id used as the select value */
5
+ id: string;
6
+ /** display name incl. version/build when known */
7
+ name: string;
8
+ executablePath: string;
9
+ source: BrowserSource;
10
+ /** puppeteer buildId, only for puppeteer-managed browsers */
11
+ buildId?: string;
12
+ /** puppeteer Browser id (e.g. "chrome"), only for puppeteer-managed */
13
+ browser?: string;
14
+ }
15
+ export declare class BrowserDetector {
16
+ private readonly platform;
17
+ private readonly home;
18
+ private readonly cachePath;
19
+ private readonly env;
20
+ constructor(platform: Platform, home: string, cachePath: string, env?: NodeJS.ProcessEnv);
21
+ /** Browsers installed system-wide, discovered via the OS tool registry. */
22
+ detectSystemBrowsers(): BrowserOption[];
23
+ /** Browsers previously downloaded into the puppeteer cache. */
24
+ detectPuppeteerBrowsers(): BrowserOption[];
25
+ /** All detected browsers, puppeteer-managed first then system. */
26
+ detectAll(): BrowserOption[];
27
+ }
@@ -0,0 +1,47 @@
1
+ import { Cache } from "@puppeteer/browsers";
2
+ import { BROWSERS } from "../os/tool-registry.js";
3
+ import { detectTools } from "../os/detector-helper.js";
4
+ export class BrowserDetector {
5
+ platform;
6
+ home;
7
+ cachePath;
8
+ env;
9
+ constructor(platform, home, cachePath, env = process.env) {
10
+ this.platform = platform;
11
+ this.home = home;
12
+ this.cachePath = cachePath;
13
+ this.env = env;
14
+ }
15
+ /** Browsers installed system-wide, discovered via the OS tool registry. */
16
+ detectSystemBrowsers() {
17
+ return detectTools(BROWSERS, this.platform, this.home, this.env).map((tool) => ({
18
+ id: `system:${tool.id}`,
19
+ name: tool.name,
20
+ executablePath: tool.executablePath,
21
+ source: "system",
22
+ }));
23
+ }
24
+ /** Browsers previously downloaded into the puppeteer cache. */
25
+ detectPuppeteerBrowsers() {
26
+ const cache = new Cache(this.cachePath);
27
+ let installed;
28
+ try {
29
+ installed = cache.getInstalledBrowsers();
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ return installed.map((browser) => ({
35
+ id: `puppeteer:${browser.browser}@${browser.buildId}`,
36
+ name: `${browser.browser} ${browser.buildId} (puppeteer)`,
37
+ executablePath: browser.executablePath,
38
+ source: "puppeteer",
39
+ buildId: browser.buildId,
40
+ browser: browser.browser,
41
+ }));
42
+ }
43
+ /** All detected browsers, puppeteer-managed first then system. */
44
+ detectAll() {
45
+ return [...this.detectPuppeteerBrowsers(), ...this.detectSystemBrowsers()];
46
+ }
47
+ }
@@ -0,0 +1,43 @@
1
+ import { InstalledBrowser } from "@puppeteer/browsers";
2
+ import { CliServiceInterface } from "../../cli/index.js";
3
+ export type BrowserChannel = "stable" | "experimental";
4
+ export interface InstalledBrowserInfo {
5
+ browser: string;
6
+ buildId: string;
7
+ executablePath: string;
8
+ }
9
+ /**
10
+ * Thin wrapper around `@puppeteer/browsers` for installing, listing and
11
+ * removing Chrome builds. Kept UI-agnostic apart from an optional progress
12
+ * spinner, so it can be reused by the wizard and the `sk browser` commands.
13
+ */
14
+ export declare class BrowserManager {
15
+ private readonly cacheDir;
16
+ private readonly cli?;
17
+ constructor(cacheDir: string, cli?: CliServiceInterface);
18
+ /**
19
+ * Resolves the concrete buildId for a channel (e.g. "stable" -> "139.0...").
20
+ */
21
+ resolveBuildId(channel: BrowserChannel): Promise<string>;
22
+ /**
23
+ * Installs Chrome for the given channel and returns the installed build.
24
+ */
25
+ installChannel(channel: BrowserChannel): Promise<InstalledBrowser>;
26
+ /**
27
+ * Installs a specific Chrome buildId and returns the installed build.
28
+ */
29
+ installBuildId(buildId: string): Promise<InstalledBrowser>;
30
+ /**
31
+ * Lists all browsers currently present in the puppeteer cache.
32
+ */
33
+ list(): InstalledBrowserInfo[];
34
+ /**
35
+ * Removes a single browser build from the puppeteer cache.
36
+ */
37
+ remove(browser: string, buildId: string): Promise<void>;
38
+ /**
39
+ * Removes all browser builds from the puppeteer cache.
40
+ */
41
+ removeAll(): Promise<void>;
42
+ private getPlatform;
43
+ }
@@ -0,0 +1,101 @@
1
+ import { Browser, detectBrowserPlatform, install, resolveBuildId, uninstall, Cache, } from "@puppeteer/browsers";
2
+ /**
3
+ * Maps the wizard's user-facing channel to a puppeteer build tag.
4
+ * "experimental" follows Chrome Canary; "stable" follows the stable channel.
5
+ */
6
+ function channelToTag(channel) {
7
+ return channel === "experimental" ? "canary" : "stable";
8
+ }
9
+ /**
10
+ * Thin wrapper around `@puppeteer/browsers` for installing, listing and
11
+ * removing Chrome builds. Kept UI-agnostic apart from an optional progress
12
+ * spinner, so it can be reused by the wizard and the `sk browser` commands.
13
+ */
14
+ export class BrowserManager {
15
+ cacheDir;
16
+ cli;
17
+ constructor(cacheDir, cli) {
18
+ this.cacheDir = cacheDir;
19
+ this.cli = cli;
20
+ }
21
+ /**
22
+ * Resolves the concrete buildId for a channel (e.g. "stable" -> "139.0...").
23
+ */
24
+ async resolveBuildId(channel) {
25
+ const platform = this.getPlatform();
26
+ return resolveBuildId(Browser.CHROME, platform, channelToTag(channel));
27
+ }
28
+ /**
29
+ * Installs Chrome for the given channel and returns the installed build.
30
+ */
31
+ async installChannel(channel) {
32
+ const buildId = await this.resolveBuildId(channel);
33
+ return this.installBuildId(buildId);
34
+ }
35
+ /**
36
+ * Installs a specific Chrome buildId and returns the installed build.
37
+ */
38
+ async installBuildId(buildId) {
39
+ const platform = this.getPlatform();
40
+ const actionName = "browser-install";
41
+ this.cli?.startAction(actionName, `Downloading Chrome ${buildId}…`);
42
+ try {
43
+ const installed = await install({
44
+ browser: Browser.CHROME,
45
+ buildId,
46
+ cacheDir: this.cacheDir,
47
+ platform,
48
+ });
49
+ this.cli?.successAction(actionName, `Installed Chrome ${buildId}`);
50
+ return installed;
51
+ }
52
+ catch (error) {
53
+ this.cli?.failAction(actionName, `Failed to install Chrome ${buildId}: ${error?.message ?? error}`);
54
+ throw error;
55
+ }
56
+ }
57
+ /**
58
+ * Lists all browsers currently present in the puppeteer cache.
59
+ */
60
+ list() {
61
+ const cache = new Cache(this.cacheDir);
62
+ let installed;
63
+ try {
64
+ installed = cache.getInstalledBrowsers();
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ return installed.map((browser) => ({
70
+ browser: browser.browser,
71
+ buildId: browser.buildId,
72
+ executablePath: browser.executablePath,
73
+ }));
74
+ }
75
+ /**
76
+ * Removes a single browser build from the puppeteer cache.
77
+ */
78
+ async remove(browser, buildId) {
79
+ await uninstall({
80
+ browser: browser,
81
+ buildId,
82
+ cacheDir: this.cacheDir,
83
+ platform: this.getPlatform(),
84
+ });
85
+ }
86
+ /**
87
+ * Removes all browser builds from the puppeteer cache.
88
+ */
89
+ async removeAll() {
90
+ for (const installed of this.list()) {
91
+ await this.remove(installed.browser, installed.buildId);
92
+ }
93
+ }
94
+ getPlatform() {
95
+ const platform = detectBrowserPlatform();
96
+ if (!platform) {
97
+ throw new Error("Unable to detect browser platform for this OS");
98
+ }
99
+ return platform;
100
+ }
101
+ }
@@ -0,0 +1,28 @@
1
+ import { CliServiceInterface } from "../../cli/index.js";
2
+ import { CliConfig, UserCliConfig } from "../../../helpers/cli-config.js";
3
+ import { BrowserDetector } from "./browser-detector.js";
4
+ import { BrowserManager } from "./browser-manager.js";
5
+ export declare class BrowserSetupService {
6
+ private readonly cli;
7
+ private readonly cliConfig;
8
+ private readonly userConfig;
9
+ private readonly detector;
10
+ private readonly manager;
11
+ constructor(cli: CliServiceInterface, cliConfig: CliConfig, userConfig: UserCliConfig, detector: BrowserDetector, manager: BrowserManager);
12
+ /**
13
+ * Interactive browser selection: pick a detected browser or download one via
14
+ * puppeteer, then persist the choice.
15
+ */
16
+ run(): Promise<void>;
17
+ /**
18
+ * Checks — at most once per reminder interval — whether the puppeteer-managed
19
+ * browser has a newer build and offers to update it. Only applies to browsers
20
+ * we installed (i.e. a channel is configured). Safe to call on every command;
21
+ * callers must ensure the session is interactive first.
22
+ */
23
+ remindIfDue(): Promise<void>;
24
+ private installAndPersist;
25
+ private askReminderDays;
26
+ private isReminderDue;
27
+ private nowIso;
28
+ }
@@ -0,0 +1,136 @@
1
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
2
+ export class BrowserSetupService {
3
+ cli;
4
+ cliConfig;
5
+ userConfig;
6
+ detector;
7
+ manager;
8
+ constructor(cli, cliConfig, userConfig, detector, manager) {
9
+ this.cli = cli;
10
+ this.cliConfig = cliConfig;
11
+ this.userConfig = userConfig;
12
+ this.detector = detector;
13
+ this.manager = manager;
14
+ }
15
+ /**
16
+ * Interactive browser selection: pick a detected browser or download one via
17
+ * puppeteer, then persist the choice.
18
+ */
19
+ async run() {
20
+ const detected = this.detector.detectAll();
21
+ const choices = [
22
+ ...detected.map((browser) => ({
23
+ name: `${browser.name} ${this.cli.style.grey(browser.executablePath)}`,
24
+ value: browser.id,
25
+ })),
26
+ {
27
+ name: "Download Chrome (stable) via puppeteer",
28
+ value: "install:stable",
29
+ },
30
+ {
31
+ name: "Download Chrome (experimental / canary) via puppeteer",
32
+ value: "install:experimental",
33
+ },
34
+ ];
35
+ const currentDefault = detected.find((browser) => browser.executablePath === this.userConfig.chromePath)?.id;
36
+ const selected = await this.cli.select("Which browser should Speed Kit use?", choices, currentDefault);
37
+ if (selected.startsWith("install:")) {
38
+ const channel = selected.slice("install:".length);
39
+ await this.installAndPersist(channel);
40
+ return;
41
+ }
42
+ const chosen = detected.find((browser) => browser.id === selected);
43
+ if (!chosen) {
44
+ this.cli.writeError("Selected browser could not be resolved.");
45
+ return;
46
+ }
47
+ this.cliConfig.save({
48
+ chromePath: chosen.executablePath,
49
+ // a manually selected browser is not update-managed by the wizard, so
50
+ // clear the channel to disable the update reminder for it
51
+ browserChannel: "",
52
+ browserBuildId: chosen.buildId ?? "",
53
+ });
54
+ this.cli.writeSuccess(`Using ${chosen.name} (${chosen.executablePath}).`);
55
+ }
56
+ /**
57
+ * Checks — at most once per reminder interval — whether the puppeteer-managed
58
+ * browser has a newer build and offers to update it. Only applies to browsers
59
+ * we installed (i.e. a channel is configured). Safe to call on every command;
60
+ * callers must ensure the session is interactive first.
61
+ */
62
+ async remindIfDue() {
63
+ const channel = this.userConfig.browserChannel;
64
+ if (channel !== "stable" && channel !== "experimental") {
65
+ return;
66
+ }
67
+ if (!this.isReminderDue()) {
68
+ return;
69
+ }
70
+ let latestBuildId;
71
+ try {
72
+ latestBuildId = await this.manager.resolveBuildId(channel);
73
+ }
74
+ catch {
75
+ // offline / resolution failure: try again next interval
76
+ return;
77
+ }
78
+ // stamp the check regardless so we don't re-check until the next interval
79
+ this.cliConfig.save({ lastBrowserUpdateCheck: this.nowIso() });
80
+ if (latestBuildId === this.userConfig.browserBuildId) {
81
+ return;
82
+ }
83
+ if (this.userConfig.browserAutoUpdate) {
84
+ await this.installAndPersist(channel);
85
+ return;
86
+ }
87
+ const update = await this.cli.confirm(`A newer Chrome ${channel} build (${latestBuildId}) is available. Update now?`, true);
88
+ if (update) {
89
+ await this.installAndPersist(channel);
90
+ return;
91
+ }
92
+ await this.askReminderDays();
93
+ }
94
+ async installAndPersist(channel) {
95
+ const installed = await this.manager.installChannel(channel);
96
+ this.cliConfig.save({
97
+ chromePath: installed.executablePath,
98
+ browserChannel: channel,
99
+ browserBuildId: installed.buildId,
100
+ lastBrowserUpdateCheck: this.nowIso(),
101
+ });
102
+ const autoUpdate = await this.cli.confirm("Automatically update the browser when a newer build is available?", this.userConfig.browserAutoUpdate);
103
+ this.cliConfig.save({ browserAutoUpdate: autoUpdate });
104
+ if (!autoUpdate) {
105
+ await this.askReminderDays();
106
+ }
107
+ }
108
+ async askReminderDays() {
109
+ const answer = await this.cli.prompt("Remind me to check for browser updates in how many days?", {
110
+ defaultAnswer: String(this.userConfig.updateReminderDays || 3),
111
+ validator: (value) => /^\d+$/.test(value.trim()) ? "" : "Enter a whole number of days",
112
+ });
113
+ const days = Number.parseInt(answer, 10);
114
+ if (!Number.isNaN(days) && days > 0) {
115
+ this.cliConfig.save({
116
+ updateReminderDays: days,
117
+ lastBrowserUpdateCheck: this.nowIso(),
118
+ });
119
+ }
120
+ }
121
+ isReminderDue() {
122
+ const days = this.userConfig.updateReminderDays || 3;
123
+ const last = this.userConfig.lastBrowserUpdateCheck;
124
+ if (!last) {
125
+ return true;
126
+ }
127
+ const lastMs = new Date(last).getTime();
128
+ if (Number.isNaN(lastMs)) {
129
+ return true;
130
+ }
131
+ return (Date.now() - lastMs) / MS_PER_DAY >= days;
132
+ }
133
+ nowIso() {
134
+ return new Date().toISOString();
135
+ }
136
+ }
@@ -0,0 +1,14 @@
1
+ import { Platform } from "../os/tool-registry.js";
2
+ export interface DetectedEditor {
3
+ id: string;
4
+ name: string;
5
+ executablePath: string;
6
+ diffExecTemplate: string;
7
+ }
8
+ export declare class DiffToolDetector {
9
+ private readonly platform;
10
+ private readonly home;
11
+ private readonly env;
12
+ constructor(platform: Platform, home: string, env?: NodeJS.ProcessEnv);
13
+ detect(): DetectedEditor[];
14
+ }
@@ -0,0 +1,27 @@
1
+ import { EDITORS } from "../os/tool-registry.js";
2
+ import { detectTool } from "../os/detector-helper.js";
3
+ export class DiffToolDetector {
4
+ platform;
5
+ home;
6
+ env;
7
+ constructor(platform, home, env = process.env) {
8
+ this.platform = platform;
9
+ this.home = home;
10
+ this.env = env;
11
+ }
12
+ detect() {
13
+ const detected = [];
14
+ for (const editor of EDITORS) {
15
+ const result = detectTool(editor, this.platform, this.home, this.env);
16
+ if (result) {
17
+ detected.push({
18
+ id: editor.id,
19
+ name: editor.name,
20
+ executablePath: result.executablePath,
21
+ diffExecTemplate: editor.diffExecTemplate,
22
+ });
23
+ }
24
+ }
25
+ return detected;
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ import { CliServiceInterface } from "../../cli/index.js";
2
+ import { CliConfig, UserCliConfig } from "../../../helpers/cli-config.js";
3
+ import { DiffToolDetector } from "./difftool-detector.js";
4
+ export declare class DiffToolSetupService {
5
+ private readonly cli;
6
+ private readonly cliConfig;
7
+ private readonly userConfig;
8
+ private readonly detector;
9
+ constructor(cli: CliServiceInterface, cliConfig: CliConfig, userConfig: UserCliConfig, detector: DiffToolDetector);
10
+ /**
11
+ * Detects installed editors/diff tools, lets the user pick one, and persists
12
+ * it as diffExec + diffExecTemplate + diffTool.
13
+ */
14
+ run(): Promise<void>;
15
+ }
@@ -0,0 +1,46 @@
1
+ export class DiffToolSetupService {
2
+ cli;
3
+ cliConfig;
4
+ userConfig;
5
+ detector;
6
+ constructor(cli, cliConfig, userConfig, detector) {
7
+ this.cli = cli;
8
+ this.cliConfig = cliConfig;
9
+ this.userConfig = userConfig;
10
+ this.detector = detector;
11
+ }
12
+ /**
13
+ * Detects installed editors/diff tools, lets the user pick one, and persists
14
+ * it as diffExec + diffExecTemplate + diffTool.
15
+ */
16
+ async run() {
17
+ const detected = this.detector.detect();
18
+ if (detected.length === 0) {
19
+ this.cli.writeWarning("No supported diff tool (IntelliJ / VS Code family) was found. " +
20
+ "You can set diffExec manually in the config later.");
21
+ return;
22
+ }
23
+ const choices = [
24
+ ...detected.map((editor) => ({
25
+ name: `${editor.name} ${this.cli.style.grey(editor.executablePath)}`,
26
+ value: editor.id,
27
+ })),
28
+ { name: "Skip — configure manually later", value: "skip" },
29
+ ];
30
+ const selected = await this.cli.select("Which diff tool should Speed Kit use?", choices, this.userConfig.diffTool || undefined);
31
+ if (selected === "skip") {
32
+ return;
33
+ }
34
+ const chosen = detected.find((editor) => editor.id === selected);
35
+ if (!chosen) {
36
+ this.cli.writeError("Selected diff tool could not be resolved.");
37
+ return;
38
+ }
39
+ this.cliConfig.save({
40
+ diffTool: chosen.id,
41
+ diffExec: chosen.executablePath,
42
+ diffExecTemplate: chosen.diffExecTemplate,
43
+ });
44
+ this.cli.writeSuccess(`Using ${chosen.name} for diffs.`);
45
+ }
46
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./setup-context.js";
2
+ export * from "./setup-service.js";
3
+ export * from "./setup-service-factory.js";
4
+ export * from "./browser/browser-detector.js";
5
+ export * from "./browser/browser-manager.js";
6
+ export * from "./browser/browser-setup-service.js";
7
+ export * from "./difftool/difftool-detector.js";
8
+ export * from "./difftool/difftool-setup-service.js";
9
+ export * from "./os/tool-registry.js";
10
+ export * from "./os/detector-helper.js";