@flowscripter/dynamic-cli-framework 6.0.0 → 7.0.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 (124) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.ts +10 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +7 -2
  5. package/dist/src/cli/BaseCLI.d.ts +8 -0
  6. package/dist/src/cli/BaseCLI.d.ts.map +1 -1
  7. package/dist/src/cli/BaseCLI.js +37 -4
  8. package/dist/src/launcher.d.ts +3 -3
  9. package/dist/src/launcher.d.ts.map +1 -1
  10. package/dist/src/launcher.js +6 -4
  11. package/dist/src/runtime/lifecycle/PriorityTaskList.d.ts +22 -0
  12. package/dist/src/runtime/lifecycle/PriorityTaskList.d.ts.map +1 -0
  13. package/dist/src/runtime/lifecycle/PriorityTaskList.js +23 -0
  14. package/dist/src/runtime/runner.d.ts +15 -7
  15. package/dist/src/runtime/runner.d.ts.map +1 -1
  16. package/dist/src/runtime/runner.js +82 -53
  17. package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts +26 -82
  18. package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts.map +1 -1
  19. package/dist/src/service/configuration/ConfigurationServiceProvider.js +68 -163
  20. package/dist/src/service/configuration/DefaultKeyValueService.d.ts +6 -3
  21. package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
  22. package/dist/src/service/configuration/DefaultKeyValueService.js +7 -24
  23. package/dist/src/service/configuration/DefaultSecretService.d.ts +1 -3
  24. package/dist/src/service/configuration/DefaultSecretService.d.ts.map +1 -1
  25. package/dist/src/service/configuration/DefaultSecretService.js +2 -10
  26. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts +105 -0
  27. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -0
  28. package/dist/src/service/configuration/KeyValueServiceProvider.js +201 -0
  29. package/dist/src/service/configuration/command/ConfigCommand.d.ts +4 -5
  30. package/dist/src/service/configuration/command/ConfigCommand.d.ts.map +1 -1
  31. package/dist/src/service/configuration/command/ConfigCommand.js +7 -6
  32. package/dist/src/service/configuration/command/DumpConfigCommand.d.ts +7 -3
  33. package/dist/src/service/configuration/command/DumpConfigCommand.d.ts.map +1 -1
  34. package/dist/src/service/configuration/command/DumpConfigCommand.js +10 -5
  35. package/dist/src/service/fetch/DefaultFetchService.d.ts +2 -2
  36. package/dist/src/service/fetch/DefaultFetchService.d.ts.map +1 -1
  37. package/dist/src/service/fetch/DefaultFetchService.js +17 -12
  38. package/dist/src/service/fetch/FetchServiceProvider.d.ts.map +1 -1
  39. package/dist/src/service/fetch/FetchServiceProvider.js +2 -3
  40. package/dist/src/service/printer/PrinterServiceProvider.d.ts.map +1 -1
  41. package/dist/src/service/printer/PrinterServiceProvider.js +11 -7
  42. package/dist/src/service/printer/command/DarkModeCommand.d.ts +3 -5
  43. package/dist/src/service/printer/command/DarkModeCommand.d.ts.map +1 -1
  44. package/dist/src/service/printer/command/DarkModeCommand.js +6 -6
  45. package/dist/src/service/printer/command/LogLevelCommand.d.ts +3 -5
  46. package/dist/src/service/printer/command/LogLevelCommand.d.ts.map +1 -1
  47. package/dist/src/service/printer/command/LogLevelCommand.js +9 -9
  48. package/dist/src/service/printer/command/NoColorCommand.d.ts +3 -5
  49. package/dist/src/service/printer/command/NoColorCommand.d.ts.map +1 -1
  50. package/dist/src/service/printer/command/NoColorCommand.js +6 -6
  51. package/dist/src/service/printer/command/NoHyperlinksCommand.d.ts +2 -4
  52. package/dist/src/service/printer/command/NoHyperlinksCommand.d.ts.map +1 -1
  53. package/dist/src/service/printer/command/NoHyperlinksCommand.js +5 -5
  54. package/dist/src/service/prompter/PrompterServiceProvider.js +1 -1
  55. package/dist/src/service/prompter/command/NoPromptCommand.d.ts +2 -4
  56. package/dist/src/service/prompter/command/NoPromptCommand.d.ts.map +1 -1
  57. package/dist/src/service/prompter/command/NoPromptCommand.js +5 -5
  58. package/dist/src/service/shutdown/DefaultShutdownService.d.ts +4 -3
  59. package/dist/src/service/shutdown/DefaultShutdownService.d.ts.map +1 -1
  60. package/dist/src/service/shutdown/DefaultShutdownService.js +4 -3
  61. package/dist/src/service/shutdown/ShutdownServiceProvider.js +2 -2
  62. package/dist/src/service/spawn/DefaultSpawnService.d.ts +2 -2
  63. package/dist/src/service/spawn/DefaultSpawnService.d.ts.map +1 -1
  64. package/dist/src/service/spawn/DefaultSpawnService.js +17 -14
  65. package/dist/src/service/spawn/SpawnServiceProvider.d.ts.map +1 -1
  66. package/dist/src/service/spawn/SpawnServiceProvider.js +1 -4
  67. package/dist/src/service/startup/DefaultStartupService.d.ts +10 -0
  68. package/dist/src/service/startup/DefaultStartupService.d.ts.map +1 -0
  69. package/dist/src/service/startup/DefaultStartupService.js +13 -0
  70. package/dist/src/service/startup/StartupServiceProvider.d.ts +17 -0
  71. package/dist/src/service/startup/StartupServiceProvider.d.ts.map +1 -0
  72. package/dist/src/service/startup/StartupServiceProvider.js +26 -0
  73. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts +10 -4
  74. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
  75. package/dist/src/service/upgrade/DefaultUpgradeService.js +156 -37
  76. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts +10 -0
  77. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts.map +1 -1
  78. package/dist/src/service/upgrade/UpgradeServiceProvider.js +25 -6
  79. package/dist/src/service/upgrade/command/UpgradeSubCommand.d.ts +0 -3
  80. package/dist/src/service/upgrade/command/UpgradeSubCommand.d.ts.map +1 -1
  81. package/dist/src/service/upgrade/command/UpgradeSubCommand.js +5 -13
  82. package/dist/src/startup/banner/bannerStartupTask.d.ts +17 -0
  83. package/dist/src/startup/banner/bannerStartupTask.d.ts.map +1 -0
  84. package/dist/src/startup/banner/bannerStartupTask.js +66 -0
  85. package/dist/src/{service → startup}/banner/command/NoBannerCommand.d.ts +8 -2
  86. package/dist/src/startup/banner/command/NoBannerCommand.d.ts.map +1 -0
  87. package/dist/src/{service → startup}/banner/command/NoBannerCommand.js +4 -4
  88. package/index.ts +19 -3
  89. package/package.json +7 -7
  90. package/src/cli/BaseCLI.ts +49 -3
  91. package/src/launcher.ts +7 -2
  92. package/src/runtime/lifecycle/PriorityTaskList.ts +34 -0
  93. package/src/runtime/runner.ts +106 -67
  94. package/src/service/configuration/ConfigurationServiceProvider.ts +80 -180
  95. package/src/service/configuration/DefaultKeyValueService.ts +8 -27
  96. package/src/service/configuration/DefaultSecretService.ts +3 -13
  97. package/src/service/configuration/KeyValueServiceProvider.ts +246 -0
  98. package/src/service/configuration/command/ConfigCommand.ts +12 -11
  99. package/src/service/configuration/command/DumpConfigCommand.ts +10 -9
  100. package/src/service/fetch/DefaultFetchService.ts +18 -12
  101. package/src/service/fetch/FetchServiceProvider.ts +2 -4
  102. package/src/service/printer/PrinterServiceProvider.ts +11 -7
  103. package/src/service/printer/command/DarkModeCommand.ts +7 -8
  104. package/src/service/printer/command/LogLevelCommand.ts +10 -11
  105. package/src/service/printer/command/NoColorCommand.ts +7 -8
  106. package/src/service/printer/command/NoHyperlinksCommand.ts +6 -7
  107. package/src/service/prompter/PrompterServiceProvider.ts +1 -1
  108. package/src/service/prompter/command/NoPromptCommand.ts +6 -7
  109. package/src/service/shutdown/DefaultShutdownService.ts +5 -4
  110. package/src/service/shutdown/ShutdownServiceProvider.ts +2 -2
  111. package/src/service/spawn/DefaultSpawnService.ts +18 -14
  112. package/src/service/spawn/SpawnServiceProvider.ts +1 -5
  113. package/src/service/startup/DefaultStartupService.ts +17 -0
  114. package/src/service/startup/StartupServiceProvider.ts +36 -0
  115. package/src/service/upgrade/DefaultUpgradeService.ts +202 -42
  116. package/src/service/upgrade/UpgradeServiceProvider.ts +31 -8
  117. package/src/service/upgrade/command/UpgradeSubCommand.ts +5 -15
  118. package/src/startup/banner/bannerStartupTask.ts +96 -0
  119. package/src/{service → startup}/banner/command/NoBannerCommand.ts +12 -5
  120. package/dist/src/service/banner/BannerServiceProvider.d.ts +0 -29
  121. package/dist/src/service/banner/BannerServiceProvider.d.ts.map +0 -1
  122. package/dist/src/service/banner/BannerServiceProvider.js +0 -69
  123. package/dist/src/service/banner/command/NoBannerCommand.d.ts.map +0 -1
  124. package/src/service/banner/BannerServiceProvider.ts +0 -89
@@ -57,8 +57,8 @@ export default class ShutdownServiceProvider implements ServiceProvider {
57
57
  process.removeListener("beforeExit", ShutdownServiceProvider.shutdown);
58
58
  process.removeListener("SIGINT", ShutdownServiceProvider.onInterrupt);
59
59
  process.removeListener("SIGTERM", ShutdownServiceProvider.onTerminate);
60
- for await (const callback of DefaultShutdownService.callbacks) {
61
- await callback();
60
+ for (const task of DefaultShutdownService.taskList.sorted()) {
61
+ await task.run();
62
62
  }
63
63
  } catch (error) {
64
64
  logger.error("shutdown error: %s", (error as Error).message);
@@ -1,11 +1,13 @@
1
1
  import process from "node:process";
2
2
  import type {
3
+ Context,
3
4
  PrinterService,
4
5
  ShutdownService,
5
6
  SpawnOptions,
6
7
  SpawnResult,
7
8
  SpawnService,
8
9
  } from "@flowscripter/dynamic-cli-framework-api";
10
+ import { PRINTER_SERVICE_ID, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
9
11
  import getLogger from "../../util/logger.ts";
10
12
 
11
13
  const logger = getLogger("DefaultSpawnService");
@@ -33,12 +35,10 @@ export function resolveForPlatform(command: ReadonlyArray<string>): string[] {
33
35
  }
34
36
 
35
37
  export default class DefaultSpawnService implements SpawnService {
36
- #printerService: PrinterService | undefined;
37
- #shutdownService: ShutdownService | undefined;
38
+ #context: Context | undefined;
38
39
 
39
- public setDependencies(printerService: PrinterService, shutdownService: ShutdownService): void {
40
- this.#printerService = printerService;
41
- this.#shutdownService = shutdownService;
40
+ public setContext(context: Context): void {
41
+ this.#context = context;
42
42
  }
43
43
 
44
44
  async #terminate(
@@ -90,11 +90,11 @@ export default class DefaultSpawnService implements SpawnService {
90
90
  command: ReadonlyArray<string>,
91
91
  options: SpawnOptions = {},
92
92
  ): Promise<SpawnResult> {
93
- if (this.#printerService === undefined || this.#shutdownService === undefined) {
94
- throw new Error("DefaultSpawnService.spawn() called before setDependencies()");
93
+ if (this.#context === undefined) {
94
+ throw new Error("DefaultSpawnService.spawn() called before setContext()");
95
95
  }
96
- const printerService = this.#printerService;
97
- const shutdownService = this.#shutdownService;
96
+ const printerService = this.#context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
97
+ const shutdownService = this.#context.getServiceById(SHUTDOWN_SERVICE_ID) as ShutdownService;
98
98
  const mode = options.mode ?? "inherit";
99
99
  const longRunning = options.longRunning ?? true;
100
100
 
@@ -122,11 +122,15 @@ export default class DefaultSpawnService implements SpawnService {
122
122
  shutdownService.enterLongRunningMode();
123
123
  }
124
124
 
125
- shutdownService.addShutdownListener(async () => {
126
- if (settled) {
127
- return;
128
- }
129
- await this.#terminate(proc, command);
125
+ shutdownService.registerTask({
126
+ id: `spawn:${command.join(" ")}`,
127
+ priority: 0,
128
+ run: async () => {
129
+ if (settled) {
130
+ return;
131
+ }
132
+ await this.#terminate(proc, command);
133
+ },
130
134
  });
131
135
 
132
136
  // These are intentionally not awaited here - they only complete once the child's stdout/
@@ -1,8 +1,6 @@
1
1
  import type { ServiceInfo, ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
2
2
  import type { Context } from "@flowscripter/dynamic-cli-framework-api";
3
3
  import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
4
- import type { PrinterService, ShutdownService } from "@flowscripter/dynamic-cli-framework-api";
5
- import { PRINTER_SERVICE_ID, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
6
4
  import { SPAWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
7
5
  import DefaultSpawnService from "./DefaultSpawnService.ts";
8
6
 
@@ -24,9 +22,7 @@ export default class SpawnServiceProvider implements ServiceProvider {
24
22
  }
25
23
 
26
24
  public initService(context: Context): Promise<void> {
27
- const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
28
- const shutdownService = context.getServiceById(SHUTDOWN_SERVICE_ID) as ShutdownService;
29
- this.#spawnService!.setDependencies(printerService, shutdownService);
25
+ this.#spawnService!.setContext(context);
30
26
  return Promise.resolve();
31
27
  }
32
28
  }
@@ -0,0 +1,17 @@
1
+ import type { StartupService, StartupTask } from "@flowscripter/dynamic-cli-framework-api";
2
+ import PriorityTaskList from "../../runtime/lifecycle/PriorityTaskList.ts";
3
+
4
+ export default class DefaultStartupService implements StartupService {
5
+ readonly #taskList = new PriorityTaskList<StartupTask>();
6
+
7
+ public registerTask(task: StartupTask): void {
8
+ this.#taskList.add(task);
9
+ }
10
+
11
+ /**
12
+ * Return all registered {@link StartupTask} instances, in descending priority order.
13
+ */
14
+ public getTasks(): ReadonlyArray<StartupTask> {
15
+ return this.#taskList.sorted();
16
+ }
17
+ }
@@ -0,0 +1,36 @@
1
+ import type {
2
+ CLIConfig,
3
+ Context,
4
+ ServiceInfo,
5
+ ServiceProvider,
6
+ } from "@flowscripter/dynamic-cli-framework-api";
7
+ import { STARTUP_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
8
+ import DefaultStartupService from "./DefaultStartupService.ts";
9
+
10
+ /**
11
+ * Minimal internal {@link ServiceProvider} which registers a {@link DefaultStartupService} in the
12
+ * {@link Context}. There is no natural per-provider work for this service to do on init - tasks
13
+ * registered against it are run by the {@link run} orchestration loop directly, not via
14
+ * {@link initService}.
15
+ */
16
+ export default class StartupServiceProvider implements ServiceProvider {
17
+ readonly serviceId: string = STARTUP_SERVICE_ID;
18
+ readonly servicePriority: number;
19
+ readonly startupService: DefaultStartupService;
20
+
21
+ public constructor(servicePriority: number) {
22
+ this.servicePriority = servicePriority;
23
+ this.startupService = new DefaultStartupService();
24
+ }
25
+
26
+ public getServiceInfo(_cliConfig: CLIConfig): Promise<ServiceInfo> {
27
+ return Promise.resolve({
28
+ service: this.startupService,
29
+ commands: [],
30
+ });
31
+ }
32
+
33
+ public initService(_context: Context): Promise<void> {
34
+ return Promise.resolve();
35
+ }
36
+ }
@@ -1,18 +1,27 @@
1
1
  import process from "node:process";
2
+ import { realpathSync } from "node:fs";
2
3
  import { mkdtemp, rename, rm } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import type {
6
7
  CLIConfig,
8
+ Context,
7
9
  FetchService,
10
+ KeyValueService,
8
11
  PrinterService,
12
+ SettableValueNode,
9
13
  SpawnResult,
10
14
  SpawnService,
11
15
  UpgradeCheckResult,
12
16
  UpgradeResult,
17
+ ValueNode,
13
18
  } from "@flowscripter/dynamic-cli-framework-api";
14
19
  import {
20
+ FETCH_SERVICE_ID,
15
21
  InstallMethod,
22
+ KEY_VALUE_SERVICE_ID,
23
+ PRINTER_SERVICE_ID,
24
+ SPAWN_SERVICE_ID,
16
25
  SupportedArch,
17
26
  SupportedOs,
18
27
  type UpgradeService,
@@ -23,11 +32,41 @@ import getLogger from "../../util/logger.ts";
23
32
 
24
33
  const logger = getLogger("DefaultUpgradeService");
25
34
 
26
- // checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
27
- // so that opportunistic call is raced against this timeout so it never stalls CLI startup.
28
- // A deliberate caller (waitForResult=true, e.g. the `upgrade` command) awaits checkForUpgrade()
29
- // directly with no timeout, so its underlying network/spawn calls are never artificially cut off.
30
- export const VERSION_CHECK_TIMEOUT_MS = 250;
35
+ // KeyValueService key caching the resolved InstallMethod (see detectInstallMethod()). Stored
36
+ // value shape: { method: InstallMethod, checkedAt: number } where checkedAt is Date.now() at
37
+ // detection time - refreshed on the same CACHE_TTL_MILLIS cycle as the latest-version cache below,
38
+ // rather than trusted indefinitely, so a reinstall via a different method is eventually picked up
39
+ // even outside the no-spawn signals (Cellar path, linux script prefix) that self-heal immediately.
40
+ const INSTALL_METHOD_CACHE_KEY = "install-method";
41
+
42
+ // KeyValueService key prefix caching a #getLatestVersion() lookup per install method, e.g.
43
+ // "latest-version:homebrew". Stored value shape: { version: string, checkedAt: number } where
44
+ // checkedAt is Date.now() at lookup time - see CACHE_TTL_MILLIS.
45
+ const LATEST_VERSION_CACHE_KEY_PREFIX = "latest-version:";
46
+
47
+ // How long a cached install-method or latest-version lookup is trusted before a fresh
48
+ // spawn/network lookup runs again - long enough to remove the cost from routine invocations,
49
+ // short enough that a genuine reinstall or new release surfaces within about a day.
50
+ const CACHE_TTL_MILLIS = 24 * 60 * 60 * 1000;
51
+
52
+ interface CachedInstallMethod {
53
+ method: InstallMethod;
54
+ checkedAt: number;
55
+ [key: string]: ValueNode;
56
+ }
57
+
58
+ interface CachedLatestVersion {
59
+ version: string;
60
+ checkedAt: number;
61
+ [key: string]: ValueNode;
62
+ }
63
+
64
+ // KeyValueService key holding the last checkForUpgrade() result, refreshed opportunistically by
65
+ // UpgradeServiceProvider's background StartupTask and by the `upgrade` command - shared by both so
66
+ // there's one cache, not two. The banner task only ever reads this key (kv.has()/kv.get()) - it
67
+ // never calls checkForUpgrade()/getUpgradeCheckResult() live, since checking live would stall
68
+ // startup on the network/spawn calls checkForUpgrade() makes.
69
+ export const UPGRADE_CHECK_CACHE_KEY = "upgrade-check-result";
31
70
 
32
71
  type VersionLookupResult =
33
72
  | { readonly ok: true; readonly version: string }
@@ -52,9 +91,7 @@ const OS_LABELS: Record<SupportedOs, string> = {
52
91
  };
53
92
 
54
93
  export default class DefaultUpgradeService implements UpgradeService {
55
- #spawnService: SpawnService | undefined;
56
- #fetchService: FetchService | undefined;
57
- #printerService: PrinterService | undefined;
94
+ #context: Context | undefined;
58
95
  #upgradeCheckPromise: Promise<UpgradeCheckResult> | undefined;
59
96
  readonly #config: UpgradeLocationsConfig;
60
97
  readonly #cliConfig: CLIConfig;
@@ -64,14 +101,32 @@ export default class DefaultUpgradeService implements UpgradeService {
64
101
  this.#cliConfig = cliConfig;
65
102
  }
66
103
 
67
- public setDependencies(
68
- spawnService: SpawnService | undefined,
69
- fetchService: FetchService | undefined,
70
- printerService: PrinterService | undefined,
71
- ): void {
72
- this.#spawnService = spawnService;
73
- this.#fetchService = fetchService;
74
- this.#printerService = printerService;
104
+ public setContext(context: Context): void {
105
+ this.#context = context;
106
+ }
107
+
108
+ get #spawnService(): SpawnService | undefined {
109
+ return this.#context?.doesServiceExist(SPAWN_SERVICE_ID)
110
+ ? (this.#context.getServiceById(SPAWN_SERVICE_ID) as SpawnService)
111
+ : undefined;
112
+ }
113
+
114
+ get #fetchService(): FetchService | undefined {
115
+ return this.#context?.doesServiceExist(FETCH_SERVICE_ID)
116
+ ? (this.#context.getServiceById(FETCH_SERVICE_ID) as FetchService)
117
+ : undefined;
118
+ }
119
+
120
+ get #printerService(): PrinterService | undefined {
121
+ return this.#context?.doesServiceExist(PRINTER_SERVICE_ID)
122
+ ? (this.#context.getServiceById(PRINTER_SERVICE_ID) as PrinterService)
123
+ : undefined;
124
+ }
125
+
126
+ get #keyValueService(): KeyValueService | undefined {
127
+ return this.#context?.doesServiceExist(KEY_VALUE_SERVICE_ID)
128
+ ? (this.#context.getServiceById(KEY_VALUE_SERVICE_ID) as KeyValueService)
129
+ : undefined;
75
130
  }
76
131
 
77
132
  // Mirrors SpawnInterfaceAdapter's plugin:add/plugin:remove pattern: wrap a spawned command's
@@ -108,15 +163,12 @@ export default class DefaultUpgradeService implements UpgradeService {
108
163
  return result;
109
164
  }
110
165
 
111
- public getUpgradeCheckResult(waitForResult = false): Promise<UpgradeCheckResult> {
166
+ public getUpgradeCheckResult(): Promise<UpgradeCheckResult> {
112
167
  if (!this.#upgradeCheckPromise) {
113
168
  logger.debug(() => "Starting upgrade check");
114
169
  // Only cache a non-"failed" result. A "failed" result can come from a transient issue
115
- // (e.g. a network blip, or the opportunistic startup check racing past
116
- // VERSION_CHECK_TIMEOUT_MS before checkForUpgrade() itself resolves) - caching that would
117
- // permanently deny a later, deliberate caller (e.g. the `upgrade` command explicitly
118
- // waiting via waitForResult=true) any chance of a fresh attempt for the rest of this
119
- // process's lifetime.
170
+ // (e.g. a network blip) - caching that would permanently deny a later caller any chance of
171
+ // a fresh attempt for the rest of this process's lifetime.
120
172
  this.#upgradeCheckPromise = this.checkForUpgrade().then((result) => {
121
173
  logger.debug(() => describeUpgradeCheckResult(result));
122
174
  if (result.status === "failed") {
@@ -125,15 +177,23 @@ export default class DefaultUpgradeService implements UpgradeService {
125
177
  return result;
126
178
  });
127
179
  }
128
- if (waitForResult) {
129
- return this.#upgradeCheckPromise;
180
+ return this.#upgradeCheckPromise;
181
+ }
182
+
183
+ /**
184
+ * Run (or reuse an in-flight/cached) {@link getUpgradeCheckResult}, then persist a "checked" or
185
+ * "unsupported" result to {@link UPGRADE_CHECK_CACHE_KEY} so the banner task's cheap KV read can
186
+ * pick it up on a later invocation.
187
+ */
188
+ public async refreshUpgradeCheckCache(): Promise<UpgradeCheckResult> {
189
+ const result = await this.getUpgradeCheckResult();
190
+ if (this.#keyValueService && (result.status === "checked" || result.status === "unsupported")) {
191
+ const keyValueService = this.#keyValueService;
192
+ await this.#safeKeyValueCall(() =>
193
+ keyValueService.set(UPGRADE_CHECK_CACHE_KEY, result as unknown as SettableValueNode),
194
+ );
130
195
  }
131
- return Promise.race([
132
- this.#upgradeCheckPromise,
133
- new Promise<UpgradeCheckResult>((resolve) =>
134
- setTimeout(() => resolve({ status: "pending" }), VERSION_CHECK_TIMEOUT_MS),
135
- ),
136
- ]);
196
+ return result;
137
197
  }
138
198
 
139
199
  public detectOs(): SupportedOs | undefined {
@@ -161,19 +221,84 @@ export default class DefaultUpgradeService implements UpgradeService {
161
221
  }
162
222
 
163
223
  public async detectInstallMethod(os: SupportedOs): Promise<InstallMethod | undefined> {
164
- if (os === SupportedOs.MACOS && this.#config.homebrew && (await this.#isHomebrewInstalled())) {
224
+ // Cheap, no-spawn signals are checked first and always win over the cache below, so a fresh
225
+ // install is picked up immediately rather than waiting on a stale cached method.
226
+ if (
227
+ os === SupportedOs.MACOS &&
228
+ this.#config.homebrew &&
229
+ this.#isRunningFromHomebrewCellar(this.#config.homebrew.formula)
230
+ ) {
165
231
  return InstallMethod.HOMEBREW;
166
232
  }
167
- if (os === SupportedOs.WINDOWS && this.#config.winget && (await this.#isWingetInstalled())) {
168
- return InstallMethod.WINGET;
169
- }
170
233
  if (os === SupportedOs.LINUX && this.#config.linuxScript && this.#isLinuxScriptInstall()) {
171
234
  return InstallMethod.LINUX_SCRIPT;
172
235
  }
173
- if (this.#config.githubRelease) {
174
- return InstallMethod.GITHUB_RELEASE;
236
+
237
+ // Everything else needs an external process spawn (`brew list`, `winget list`) to confirm -
238
+ // cache the resolved method so that spawn only ever happens once per keystore.
239
+ const cached = await this.#getCachedInstallMethod();
240
+ if (cached !== undefined) {
241
+ return cached;
242
+ }
243
+
244
+ let detected: InstallMethod | undefined;
245
+ if (os === SupportedOs.MACOS && this.#config.homebrew && (await this.#isHomebrewInstalled())) {
246
+ detected = InstallMethod.HOMEBREW;
247
+ } else if (
248
+ os === SupportedOs.WINDOWS &&
249
+ this.#config.winget &&
250
+ (await this.#isWingetInstalled())
251
+ ) {
252
+ detected = InstallMethod.WINGET;
253
+ } else if (this.#config.githubRelease) {
254
+ detected = InstallMethod.GITHUB_RELEASE;
255
+ }
256
+
257
+ if (detected !== undefined) {
258
+ await this.#cacheInstallMethod(detected);
259
+ }
260
+ return detected;
261
+ }
262
+
263
+ // The opportunistic startup check (see getUpgradeCheckResult()) runs detached from
264
+ // UpgradeServiceProvider.initService()'s own await chain, so it can still be running after that
265
+ // scoped KeyValueService access window has closed - a KeyValueService call landing after that
266
+ // point throws (or could land under a different service's scope entirely). Treat any such
267
+ // failure as "caching unavailable right now" rather than letting it fail the whole check.
268
+ async #safeKeyValueCall<T>(op: () => Promise<T>): Promise<T | undefined> {
269
+ try {
270
+ return await op();
271
+ } catch (error) {
272
+ logger.debug(() => `KeyValueService access failed, skipping cache: ${error}`);
273
+ return undefined;
274
+ }
275
+ }
276
+
277
+ async #getCachedInstallMethod(): Promise<InstallMethod | undefined> {
278
+ if (!this.#keyValueService) {
279
+ return undefined;
280
+ }
281
+ const keyValueService = this.#keyValueService;
282
+ const has = await this.#safeKeyValueCall(() => keyValueService.has(INSTALL_METHOD_CACHE_KEY));
283
+ if (!has) {
284
+ return undefined;
285
+ }
286
+ const cached = await this.#safeKeyValueCall(() =>
287
+ keyValueService.get<CachedInstallMethod>(INSTALL_METHOD_CACHE_KEY),
288
+ );
289
+ if (!cached || Date.now() - cached.checkedAt >= CACHE_TTL_MILLIS) {
290
+ return undefined;
291
+ }
292
+ return cached.method;
293
+ }
294
+
295
+ async #cacheInstallMethod(method: InstallMethod): Promise<void> {
296
+ if (this.#keyValueService) {
297
+ const keyValueService = this.#keyValueService;
298
+ await this.#safeKeyValueCall(() =>
299
+ keyValueService.set(INSTALL_METHOD_CACHE_KEY, { method, checkedAt: Date.now() }),
300
+ );
175
301
  }
176
- return undefined;
177
302
  }
178
303
 
179
304
  public async checkForUpgrade(
@@ -230,7 +355,7 @@ export default class DefaultUpgradeService implements UpgradeService {
230
355
  osOverride !== undefined || archOverride !== undefined || installMethodOverride !== undefined;
231
356
  const checkResult = hasOverride
232
357
  ? await this.checkForUpgrade(osOverride, archOverride, installMethodOverride)
233
- : await this.getUpgradeCheckResult(true);
358
+ : await this.getUpgradeCheckResult();
234
359
  if (checkResult.status === "unsupported") {
235
360
  return {
236
361
  ok: false,
@@ -241,10 +366,6 @@ export default class DefaultUpgradeService implements UpgradeService {
241
366
  if (checkResult.status === "failed") {
242
367
  return { ok: false, oldVersion, error: checkResult.error };
243
368
  }
244
- if (checkResult.status === "pending") {
245
- // Unreachable: checkForUpgrade() and getUpgradeCheckResult(true) always run to completion.
246
- return { ok: false, oldVersion, error: new Error("Upgrade check did not complete") };
247
- }
248
369
  if (!this.#spawnService) {
249
370
  return { ok: false, oldVersion, error: new Error("SpawnService is not available") };
250
371
  }
@@ -286,6 +407,21 @@ export default class DefaultUpgradeService implements UpgradeService {
286
407
  );
287
408
  }
288
409
 
410
+ // Homebrew relinks a formula's installed binary from its Cellar directory into a `bin/` symlink,
411
+ // so resolving the running executable's real path confirms a homebrew install without spawning
412
+ // `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
413
+ // StartupTask (see UpgradeServiceProvider) fast even though it now runs to completion.
414
+ #isRunningFromHomebrewCellar(formula: string): boolean {
415
+ let realExecutable = process.execPath;
416
+ try {
417
+ realExecutable = realpathSync(process.execPath);
418
+ } catch {
419
+ // process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
420
+ // the unresolved path rather than treating that as "not installed".
421
+ }
422
+ return realExecutable.includes(`/Cellar/${formula}/`);
423
+ }
424
+
289
425
  async #isHomebrewInstalled(): Promise<boolean> {
290
426
  if (!this.#spawnService || !this.#config.homebrew) {
291
427
  return false;
@@ -313,6 +449,30 @@ export default class DefaultUpgradeService implements UpgradeService {
313
449
  }
314
450
 
315
451
  async #getLatestVersion(installMethod: InstallMethod): Promise<VersionLookupResult> {
452
+ const cacheKey = `${LATEST_VERSION_CACHE_KEY_PREFIX}${installMethod}`;
453
+ const keyValueService = this.#keyValueService;
454
+ if (keyValueService) {
455
+ const has = await this.#safeKeyValueCall(() => keyValueService.has(cacheKey));
456
+ if (has) {
457
+ const cached = await this.#safeKeyValueCall(() =>
458
+ keyValueService.get<CachedLatestVersion>(cacheKey),
459
+ );
460
+ if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MILLIS) {
461
+ return { ok: true, version: cached.version };
462
+ }
463
+ }
464
+ }
465
+
466
+ const result = await this.#lookupLatestVersion(installMethod);
467
+ if (result.ok && keyValueService) {
468
+ await this.#safeKeyValueCall(() =>
469
+ keyValueService.set(cacheKey, { version: result.version, checkedAt: Date.now() }),
470
+ );
471
+ }
472
+ return result;
473
+ }
474
+
475
+ async #lookupLatestVersion(installMethod: InstallMethod): Promise<VersionLookupResult> {
316
476
  switch (installMethod) {
317
477
  case InstallMethod.GITHUB_RELEASE:
318
478
  case InstallMethod.LINUX_SCRIPT:
@@ -16,9 +16,30 @@ import DefaultUpgradeService from "./DefaultUpgradeService.ts";
16
16
  import { UpgradeSubCommand } from "./command/UpgradeSubCommand.ts";
17
17
  import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
18
18
  import getLogger from "../../util/logger.ts";
19
+ import type { StartupTask } from "@flowscripter/dynamic-cli-framework-api";
19
20
 
20
21
  const logger = getLogger("UpgradeServiceProvider");
21
22
 
23
+ /**
24
+ * Build the background {@link StartupTask} which opportunistically refreshes the upgrade-check
25
+ * cache (see {@link DefaultUpgradeService.refreshUpgradeCheckCache}) that the banner task reads
26
+ * from. Registered separately from {@link UpgradeServiceProvider} itself (which still runs as a
27
+ * regular blocking init task) so it can run in `"background"` mode.
28
+ */
29
+ export function createUpgradeCheckStartupTask(
30
+ upgradeService: DefaultUpgradeService,
31
+ priority: number,
32
+ ): StartupTask {
33
+ return {
34
+ id: `${UPGRADE_SERVICE_ID}-check`,
35
+ priority,
36
+ mode: "background",
37
+ run: async () => {
38
+ await upgradeService.refreshUpgradeCheckCache();
39
+ },
40
+ };
41
+ }
42
+
22
43
  export default class UpgradeServiceProvider implements ServiceProvider {
23
44
  readonly serviceId: string = UPGRADE_SERVICE_ID;
24
45
  readonly servicePriority: number;
@@ -31,12 +52,16 @@ export default class UpgradeServiceProvider implements ServiceProvider {
31
52
  this.#config = config;
32
53
  }
33
54
 
55
+ public get upgradeService(): DefaultUpgradeService | undefined {
56
+ return this.#upgradeService;
57
+ }
58
+
34
59
  public getServiceInfo(cliConfig: CLIConfig): Promise<ServiceInfo> {
35
60
  this.#cliConfig = cliConfig;
36
61
  this.#upgradeService = new DefaultUpgradeService(this.#config, cliConfig);
37
62
  return Promise.resolve({
38
63
  service: this.#upgradeService,
39
- commands: [new UpgradeSubCommand(this.#upgradeService)],
64
+ commands: [new UpgradeSubCommand()],
40
65
  });
41
66
  }
42
67
 
@@ -56,22 +81,20 @@ export default class UpgradeServiceProvider implements ServiceProvider {
56
81
  if (fetchService === undefined) {
57
82
  logger.debug(() => "FetchService not available, upgrade version checks will be unavailable");
58
83
  }
59
- const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
60
- upgradeService.setDependencies(spawnService, fetchService, printerService);
61
-
62
- void upgradeService.getUpgradeCheckResult();
84
+ const keyValueService = context.doesServiceExist(KEY_VALUE_SERVICE_ID)
85
+ ? (context.getServiceById(KEY_VALUE_SERVICE_ID) as KeyValueService)
86
+ : undefined;
87
+ upgradeService.setContext(context);
63
88
 
64
89
  if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
65
90
  logger.debug(() => "PrompterService not available, skipping auto-upgrade");
66
91
  return;
67
92
  }
68
- if (!context.doesServiceExist(KEY_VALUE_SERVICE_ID)) {
93
+ if (!keyValueService) {
69
94
  logger.debug(() => "KeyValueService not available, skipping auto-upgrade");
70
95
  return;
71
96
  }
72
97
 
73
- const keyValueService = context.getServiceById(KEY_VALUE_SERVICE_ID) as KeyValueService;
74
-
75
98
  if (await keyValueService.has("upgrade-status")) {
76
99
  const status = await keyValueService.get("upgrade-status");
77
100
  if (status === "declined") {
@@ -8,6 +8,7 @@ import {
8
8
  PRINTER_SERVICE_ID,
9
9
  SupportedArch,
10
10
  SupportedOs,
11
+ UPGRADE_SERVICE_ID,
11
12
  } from "@flowscripter/dynamic-cli-framework-api";
12
13
  import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
13
14
  import type DefaultUpgradeService from "../DefaultUpgradeService.ts";
@@ -35,14 +36,9 @@ export class UpgradeSubCommand implements SubCommand {
35
36
  },
36
37
  ];
37
38
 
38
- readonly #upgradeService: DefaultUpgradeService;
39
-
40
- public constructor(upgradeService: DefaultUpgradeService) {
41
- this.#upgradeService = upgradeService;
42
- }
43
-
44
39
  public async execute(context: Context, argumentValues: Values): Promise<void> {
45
40
  const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
41
+ const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as DefaultUpgradeService;
46
42
  const cliName = context.cliConfig.name;
47
43
  const currentVersion = context.cliConfig.version;
48
44
 
@@ -54,8 +50,8 @@ export class UpgradeSubCommand implements SubCommand {
54
50
 
55
51
  const checkResult =
56
52
  os === undefined && installMethod === undefined
57
- ? await this.#upgradeService.getUpgradeCheckResult(true)
58
- : await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
53
+ ? await upgradeService.refreshUpgradeCheckCache()
54
+ : await upgradeService.checkForUpgrade(os, arch, installMethod);
59
55
 
60
56
  await printerService.hideSpinner();
61
57
  if (checkResult.status === "unsupported") {
@@ -74,12 +70,6 @@ export class UpgradeSubCommand implements SubCommand {
74
70
  return;
75
71
  }
76
72
 
77
- if (checkResult.status === "pending") {
78
- // Unreachable: getUpgradeCheckResult(true) and checkForUpgrade() always run to completion.
79
- await printerService.error(`Failed to check for updates.\n`, Icon.FAILURE);
80
- return;
81
- }
82
-
83
73
  if (!checkResult.updateAvailable) {
84
74
  await printerService.print(
85
75
  `${cliName} is already up to date: ${checkResult.currentVersion}\n`,
@@ -88,7 +78,7 @@ export class UpgradeSubCommand implements SubCommand {
88
78
  return;
89
79
  }
90
80
 
91
- const upgradeResult = await this.#upgradeService.upgrade(os, arch, installMethod);
81
+ const upgradeResult = await upgradeService.upgrade(os, arch, installMethod);
92
82
  if (!upgradeResult.ok) {
93
83
  await printerService.error(
94
84
  `Failed to upgrade ${cliName}: ${upgradeResult.error?.message ?? "unknown error"}\n`,