@flowscripter/dynamic-cli-framework 7.0.11 → 7.1.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 (37) hide show
  1. package/dist/src/cli/BaseCLI.d.ts.map +1 -1
  2. package/dist/src/cli/BaseCLI.js +7 -1
  3. package/dist/src/cli/BaseCLIFeatureOptions.d.ts +5 -0
  4. package/dist/src/cli/BaseCLIFeatureOptions.d.ts.map +1 -1
  5. package/dist/src/cli/DefaultRuntimeCLI.js +1 -1
  6. package/dist/src/runtime/runner.d.ts +10 -1
  7. package/dist/src/runtime/runner.d.ts.map +1 -1
  8. package/dist/src/runtime/runner.js +7 -1
  9. package/dist/src/service/configuration/DefaultKeyValueService.d.ts +9 -1
  10. package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
  11. package/dist/src/service/configuration/DefaultKeyValueService.js +16 -1
  12. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -1
  13. package/dist/src/service/configuration/KeyValueServiceProvider.js +13 -6
  14. package/dist/src/service/shutdown/ShutdownServiceProvider.js +3 -3
  15. package/dist/src/service/spawn/DefaultSpawnService.d.ts.map +1 -1
  16. package/dist/src/service/spawn/DefaultSpawnService.js +1 -0
  17. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts +30 -2
  18. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
  19. package/dist/src/service/upgrade/DefaultUpgradeService.js +99 -6
  20. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts +19 -4
  21. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts.map +1 -1
  22. package/dist/src/service/upgrade/UpgradeServiceProvider.js +51 -12
  23. package/dist/src/startup/banner/bannerStartupTask.d.ts.map +1 -1
  24. package/dist/src/startup/banner/bannerStartupTask.js +9 -5
  25. package/package.json +2 -2
  26. package/src/cli/BaseCLI.ts +7 -0
  27. package/src/cli/BaseCLIFeatureOptions.ts +5 -0
  28. package/src/cli/DefaultRuntimeCLI.ts +1 -1
  29. package/src/runtime/runner.ts +17 -2
  30. package/src/service/configuration/DefaultKeyValueService.ts +22 -1
  31. package/src/service/configuration/KeyValueServiceProvider.ts +18 -6
  32. package/src/service/shutdown/ShutdownServiceProvider.ts +3 -3
  33. package/src/service/spawn/DefaultSpawnService.ts +1 -0
  34. package/src/service/upgrade/DefaultUpgradeService.ts +112 -5
  35. package/src/service/upgrade/UpgradeServiceProvider.ts +77 -19
  36. package/src/service/upgrade/command/UpgradeSubCommand.ts +2 -2
  37. package/src/startup/banner/bannerStartupTask.ts +11 -5
@@ -26,7 +26,7 @@ export function createUpgradeCheckStartupTask(upgradeService, priority) {
26
26
  }
27
27
  /**
28
28
  * Build the blocking {@link StartupTask} which offers to enable automatic upgrades and, when they
29
- * are enabled, checks for and installs a newer version (see
29
+ * are enabled, checks for and installs a newer version, optionally restarting into it (see
30
30
  * {@link UpgradeServiceProvider.runAutoUpgrade}). It is registered separately from the provider's
31
31
  * own init task so it can run at a lower priority, after consumer startup tasks such as the banner.
32
32
  */
@@ -45,9 +45,25 @@ export default class UpgradeServiceProvider {
45
45
  #upgradeService;
46
46
  #cliConfig;
47
47
  #keyValueService;
48
- constructor(servicePriority, config) {
48
+ #restartAfterAutoUpgrade;
49
+ #restartArgs = [];
50
+ /**
51
+ * @param servicePriority the priority of the service.
52
+ * @param config the upgrade locations.
53
+ * @param restartAfterAutoUpgrade whether to run the upgraded executable, with the arguments from
54
+ * {@link setRestartArgs}, after a successful automatic upgrade and end the run with its result.
55
+ */
56
+ constructor(servicePriority, config, restartAfterAutoUpgrade = false) {
49
57
  this.servicePriority = servicePriority;
50
58
  this.#config = config;
59
+ this.#restartAfterAutoUpgrade = restartAfterAutoUpgrade;
60
+ }
61
+ /**
62
+ * Set the CLI arguments to run the upgraded executable with when restarting after an automatic
63
+ * upgrade. These are kept internal to the framework and never exposed to startup tasks.
64
+ */
65
+ setRestartArgs(args) {
66
+ this.#restartArgs = args;
51
67
  }
52
68
  get upgradeService() {
53
69
  return this.#upgradeService;
@@ -87,12 +103,20 @@ export default class UpgradeServiceProvider {
87
103
  * service's scope) and, when they are enabled, check for and install a newer version. Must be
88
104
  * called after {@link initService}.
89
105
  *
106
+ * After a successful upgrade, when restarting is enabled, the upgraded executable is run with the
107
+ * arguments from {@link setRestartArgs} and its result is returned as an `exitRequest`. Does
108
+ * nothing in a process which was itself started by such a restart.
109
+ *
90
110
  * @param context the startup task's context, used to look up the Prompter and Printer services.
91
111
  */
92
112
  async runAutoUpgrade(context) {
93
113
  const upgradeService = this.#upgradeService;
94
114
  const cliConfig = this.#cliConfig;
95
115
  const keyValueService = this.#keyValueService;
116
+ if (upgradeService.restartedFromVersion !== undefined) {
117
+ logger.debug(() => `Restarted from version ${upgradeService.restartedFromVersion}, skipping auto-upgrade`);
118
+ return;
119
+ }
96
120
  if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
97
121
  logger.debug(() => "PrompterService not available, skipping auto-upgrade");
98
122
  return;
@@ -108,8 +132,7 @@ export default class UpgradeServiceProvider {
108
132
  return;
109
133
  }
110
134
  if (status === "enabled") {
111
- await this.#checkAndUpgrade(context, upgradeService, cliConfig);
112
- return;
135
+ return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
113
136
  }
114
137
  }
115
138
  const prompterService = context.getServiceById(PROMPTER_SERVICE_ID);
@@ -150,9 +173,9 @@ export default class UpgradeServiceProvider {
150
173
  return;
151
174
  }
152
175
  await keyValueService.set("upgrade-status", "enabled");
153
- await this.#checkAndUpgrade(context, upgradeService, cliConfig);
176
+ return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
154
177
  }
155
- async #checkAndUpgrade(context, upgradeService, cliConfig) {
178
+ async #checkAndUpgrade(context, upgradeService, cliConfig, keyValueService) {
156
179
  try {
157
180
  const checkResult = await upgradeService.getUpgradeCheckResult();
158
181
  if (checkResult.status === "failed") {
@@ -163,16 +186,32 @@ export default class UpgradeServiceProvider {
163
186
  return;
164
187
  }
165
188
  const upgradeResult = await upgradeService.upgrade();
166
- if (!context.doesServiceExist(PRINTER_SERVICE_ID)) {
189
+ const printerService = context.doesServiceExist(PRINTER_SERVICE_ID)
190
+ ? context.getServiceById(PRINTER_SERVICE_ID)
191
+ : undefined;
192
+ if (!upgradeResult.ok) {
193
+ await printerService?.error(`Auto-upgrade failed: ${upgradeResult.error?.message ?? "unknown error"}\n`, Icon.FAILURE);
194
+ return;
195
+ }
196
+ await printerService?.info(`${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`, Icon.SUCCESS);
197
+ if (!this.#restartAfterAutoUpgrade) {
167
198
  return;
168
199
  }
169
- const printerService = context.getServiceById(PRINTER_SERVICE_ID);
170
- if (upgradeResult.ok) {
171
- await printerService.info(`${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`, Icon.SUCCESS);
200
+ const executable = upgradeService.resolveUpgradedExecutable(checkResult.installMethod);
201
+ if (executable === undefined) {
202
+ await printerService?.info(`Restart ${cliConfig.name} to use ${upgradeResult.newVersion}\n`, Icon.INFORMATION);
203
+ return;
172
204
  }
173
- else {
174
- await printerService.error(`Auto-upgrade failed: ${upgradeResult.error?.message ?? "unknown error"}\n`, Icon.FAILURE);
205
+ // the cache write from the background upgrade check lands before the flush, so the shutdown
206
+ // flush has nothing left to write over the restarted process's configuration
207
+ await upgradeService.refreshUpgradeCheckCache();
208
+ // written now so the restarted process sees the stored upgrade-status
209
+ await keyValueService.flush();
210
+ const runState = await upgradeService.restart(executable, this.#restartArgs);
211
+ if (runState !== undefined) {
212
+ return { exitRequest: { runState } };
175
213
  }
214
+ await printerService?.warn(`${cliConfig.name} upgraded to ${upgradeResult.newVersion}; restart it to use the new version\n`, Icon.ALERT);
176
215
  }
177
216
  catch (error) {
178
217
  logger.debug(() => `Auto-upgrade check failed: ${error}`);
@@ -1 +1 @@
1
- {"version":3,"file":"bannerStartupTask.d.ts","sourceRoot":"","sources":["../../../../src/startup/banner/bannerStartupTask.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA2B,WAAW,EAAE,MAAM,yCAAyC,CAAC;AAYpG,eAAO,MAAM,sBAAsB,4DAA4D,CAAC;AAEhG;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAC7C,QAAQ,EAAE,MAAM,EAChB,QAAQ,SAAa,GACpB,WAAW,CA+Db"}
1
+ {"version":3,"file":"bannerStartupTask.d.ts","sourceRoot":"","sources":["../../../../src/startup/banner/bannerStartupTask.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA2B,WAAW,EAAE,MAAM,yCAAyC,CAAC;AAYpG,eAAO,MAAM,sBAAsB,4DAA4D,CAAC;AAEhG;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAC7C,QAAQ,EAAE,MAAM,EAChB,QAAQ,SAAa,GACpB,WAAW,CAqEb"}
@@ -1,5 +1,4 @@
1
1
  import { ASCII_BANNER_GENERATOR_SERVICE_ID, CONFIGURATION_SERVICE_ID, PRINTER_SERVICE_ID, UPGRADE_SERVICE_ID, } from "@flowscripter/dynamic-cli-framework-api";
2
- import DefaultUpgradeService from "../../service/upgrade/DefaultUpgradeService.js";
3
2
  import NoBannerCommand, {} from "./command/NoBannerCommand.js";
4
3
  export const BANNER_STARTUP_TASK_ID = "@flowscripter/dynamic-cli-framework/banner-startup-task";
5
4
  /**
@@ -26,6 +25,14 @@ export default function createBannerStartupTask(priority, fontName = "standard")
26
25
  if (!state.printBanner) {
27
26
  return;
28
27
  }
28
+ const upgradeService = context.doesServiceExist(UPGRADE_SERVICE_ID)
29
+ ? context.getServiceById(UPGRADE_SERVICE_ID)
30
+ : undefined;
31
+ // a process restarted after an automatic upgrade follows the banner already printed by the
32
+ // process which restarted it
33
+ if (upgradeService?.restartedFromVersion !== undefined) {
34
+ return;
35
+ }
29
36
  const printerService = context.getServiceById(PRINTER_SERVICE_ID);
30
37
  const asciiBannerGeneratorService = context.getServiceById(ASCII_BANNER_GENERATOR_SERVICE_ID);
31
38
  const { cliConfig } = context;
@@ -44,10 +51,7 @@ export default function createBannerStartupTask(priority, fontName = "standard")
44
51
  // result yet) shows no upgrade-available suffix. The cached result is read through the
45
52
  // upgrade service because it is stored in that service's own KeyValueService scope, and
46
53
  // it is ignored if it was recorded by a different version (e.g. before an upgrade).
47
- const upgradeService = context.doesServiceExist(UPGRADE_SERVICE_ID)
48
- ? context.getServiceById(UPGRADE_SERVICE_ID)
49
- : undefined;
50
- if (upgradeService instanceof DefaultUpgradeService) {
54
+ if (upgradeService) {
51
55
  const result = await upgradeService.getCachedUpgradeCheckResult();
52
56
  if (result?.status === "checked" &&
53
57
  result.updateAvailable &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowscripter/dynamic-cli-framework",
3
- "version": "7.0.11",
3
+ "version": "7.1.0",
4
4
  "description": "A framework for developing CLI applications which supports dynamic discovery and installation of new commands",
5
5
  "keywords": [
6
6
  "bun",
@@ -48,7 +48,7 @@
48
48
  "build": "tsc -p tsconfig.build.json"
49
49
  },
50
50
  "dependencies": {
51
- "@flowscripter/dynamic-cli-framework-api": "^4.0.6",
51
+ "@flowscripter/dynamic-cli-framework-api": "^4.1.0",
52
52
  "@flowscripter/dynamic-plugin-framework": "^2.2.14",
53
53
  "emphasize": "^7.0.0",
54
54
  "fastest-levenshtein": "^1.0.16",
@@ -171,6 +171,7 @@ export default class BaseCLI implements CLI {
171
171
  fetchServiceEnabled: false,
172
172
  upgradeServiceEnabled: false,
173
173
  upgradeLocationsConfig: { supportedPlatforms: [] },
174
+ restartAfterAutoUpgrade: false,
174
175
  pluginServiceEnabled: false,
175
176
  pluginServiceRemoteConfig: { name: "", registryUrl: "", packageJsonNamespace: "" },
176
177
  pluginServiceLocalConfig: { nodeModulesPath: "", packageJsonNamespace: "" },
@@ -356,7 +357,9 @@ export default class BaseCLI implements CLI {
356
357
  upgradeServiceProvider = new UpgradeServiceProvider(
357
358
  UPGRADE_SERVICE_PRIORITY,
358
359
  this.#options.upgradeLocationsConfig,
360
+ this.#options.restartAfterAutoUpgrade,
359
361
  );
362
+ upgradeServiceProvider.setRestartArgs(args);
360
363
  this.addServiceProvider(upgradeServiceProvider);
361
364
  }
362
365
 
@@ -474,6 +477,10 @@ export default class BaseCLI implements CLI {
474
477
  defaultCommand,
475
478
  this.#startupServiceProvider.startupService,
476
479
  );
480
+ // a startup task ended the run, having already handled its outcome itself
481
+ if (runResult.exitRequested) {
482
+ return { runState: runResult.runState };
483
+ }
477
484
  // then handle the result...
478
485
  if (runResult.runState === RunState.NO_COMMAND) {
479
486
  // print usage
@@ -16,6 +16,11 @@ export default interface BaseCLIFeatureOptions {
16
16
  readonly fetchServiceEnabled?: boolean;
17
17
  readonly upgradeServiceEnabled?: boolean;
18
18
  readonly upgradeLocationsConfig?: UpgradeLocationsConfig;
19
+ /**
20
+ * When an automatic upgrade on startup succeeds, run the upgraded executable with the same
21
+ * arguments and end this run with its result, rather than continuing on the old version.
22
+ */
23
+ readonly restartAfterAutoUpgrade?: boolean;
19
24
  readonly pluginServiceEnabled?: boolean;
20
25
  readonly pluginServiceRemoteConfig?: NpmjsPluginRepositoryConfig;
21
26
  readonly pluginServiceLocalConfig?: NpmPluginRepositoryConfig;
@@ -72,7 +72,7 @@ export default class DefaultRuntimeCLI extends BaseCLI {
72
72
  stderrTerminal,
73
73
  new TtyStyler(colorLevel, supportsHyperlinks(process.stderr)),
74
74
  keyReader,
75
- { promptingEnabled, ...options },
75
+ { promptingEnabled, restartAfterAutoUpgrade: true, ...options },
76
76
  );
77
77
  }
78
78
 
@@ -36,6 +36,12 @@ import { sortByPriority } from "./lifecycle/PriorityTaskList.ts";
36
36
 
37
37
  const logger = getLogger("runner");
38
38
 
39
+ /**
40
+ * The {@link RunResult} returned by {@link run}, with `exitRequested` set when a startup task ended
41
+ * the run early by returning an `exitRequest`.
42
+ */
43
+ export type RunnerResult = RunResult & { readonly exitRequested?: true };
44
+
39
45
  /**
40
46
  * Wrap a {@link ServiceProvider}'s `initService()` call as a {@link StartupTask} so it can be
41
47
  * merged with directly-registered {@link StartupTask}s (e.g. the banner task) into a single
@@ -560,6 +566,8 @@ async function findAndExecuteDefaultNonModifierCommand(
560
566
  * - Run the task: awaited before continuing if its mode is "blocking" (the default, and how
561
567
  * every ServiceProvider's initService() behaves); fired without awaiting if "background"
562
568
  * (errors logged, not propagated).
569
+ * - Return immediately if a "blocking" task returned an exitRequest, using its runState and
570
+ * skipping the remaining tasks and the command.
563
571
  * 2. Scan arguments for any GlobalModifierCommand clauses not provided by a StartupTask
564
572
  * - For each discovered clause:
565
573
  * - Set any argument defaults from ConfigurationServiceProvider
@@ -615,7 +623,7 @@ export async function run(
615
623
  context: Context,
616
624
  defaultCommand?: Command,
617
625
  startupService?: DefaultStartupService,
618
- ): Promise<RunResult> {
626
+ ): Promise<RunnerResult> {
619
627
  if (defaultCommand && !isSubCommand(defaultCommand) && !isGlobalCommand(defaultCommand)) {
620
628
  throw new Error(
621
629
  "If a default command is provided, if must be a global command or sub-command!",
@@ -681,7 +689,14 @@ export async function run(
681
689
  : context;
682
690
 
683
691
  if ((task.mode ?? "blocking") === "blocking") {
684
- await task.run(taskContext);
692
+ const outcome = await task.run(taskContext);
693
+ if (outcome?.exitRequest) {
694
+ logger.debug(
695
+ () =>
696
+ `Startup task '${task.id}' requested exit with run state ${outcome.exitRequest.runState}`,
697
+ );
698
+ return { runState: outcome.exitRequest.runState, exitRequested: true };
699
+ }
685
700
  } else {
686
701
  void task.run(taskContext).catch((error: unknown) => {
687
702
  logger.debug(() => `Background startup task '${task.id}' failed: ${error}`);
@@ -16,16 +16,37 @@ export default class DefaultKeyValueService implements KeyValueService {
16
16
  readonly #keyValueData: Map<string, ValueNode>;
17
17
  #dirty = false;
18
18
  readonly #secretService: DefaultSecretService | undefined;
19
+ readonly #flushToStorage: (() => Promise<void>) | undefined;
19
20
 
20
- constructor(keyValueData: Map<string, ValueNode>, secretService?: DefaultSecretService) {
21
+ /**
22
+ * @param keyValueData the scope's data.
23
+ * @param secretService optional secret service for {@link Secret} values.
24
+ * @param flushToStorage optional callback which writes every scope's pending changes to storage
25
+ * and marks them clean, invoked by {@link flush}.
26
+ */
27
+ constructor(
28
+ keyValueData: Map<string, ValueNode>,
29
+ secretService?: DefaultSecretService,
30
+ flushToStorage?: () => Promise<void>,
31
+ ) {
21
32
  this.#keyValueData = keyValueData;
22
33
  this.#secretService = secretService;
34
+ this.#flushToStorage = flushToStorage;
23
35
  }
24
36
 
25
37
  public isDirty(): boolean {
26
38
  return this.#dirty;
27
39
  }
28
40
 
41
+ public markClean(): void {
42
+ this.#dirty = false;
43
+ }
44
+
45
+ public async flush(): Promise<void> {
46
+ await this.#flushToStorage?.();
47
+ this.#dirty = false;
48
+ }
49
+
29
50
  public async get<T extends ValueNode = ValueNode>(key: string): Promise<T> {
30
51
  const value = this.#keyValueData.get(key);
31
52
  if (value === undefined) {
@@ -38,6 +38,10 @@ class UnreachableKeyValueService implements KeyValueService {
38
38
  return Promise.reject(new Error(UnreachableKeyValueService.#message));
39
39
  }
40
40
 
41
+ flush(): Promise<void> {
42
+ return Promise.reject(new Error(UnreachableKeyValueService.#message));
43
+ }
44
+
41
45
  static readonly #message =
42
46
  "KeyValueService accessed without a resolved scope - this should be unreachable";
43
47
  }
@@ -189,7 +193,9 @@ export default class KeyValueServiceProvider implements ServiceProvider {
189
193
  const secretService = this.secretServiceEnabled
190
194
  ? new DefaultSecretService(this.#cliConfigName!, `${scopeType}_${scopeKey}`)
191
195
  : undefined;
192
- const scopedService = new DefaultKeyValueService(keyValueData, secretService);
196
+ const scopedService = new DefaultKeyValueService(keyValueData, secretService, () =>
197
+ this.#flushDirtyScopes(),
198
+ );
193
199
  scopedServices.set(scopeKey, scopedService);
194
200
  return scopedService;
195
201
  }
@@ -230,15 +236,21 @@ export default class KeyValueServiceProvider implements ServiceProvider {
230
236
 
231
237
  /**
232
238
  * Flush every scoped KeyValueService's data to the configuration file, if any of them report
233
- * themselves dirty. Registered as a low-priority ShutdownTask (see {@link initService}) so it
234
- * runs once, after other shutdown cleanup, regardless of how long any scope was written to.
239
+ * themselves dirty, then mark them all clean. Registered as a low-priority ShutdownTask (see
240
+ * {@link initService}) so it runs once, after other shutdown cleanup, regardless of how long any
241
+ * scope was written to. Also backs {@link KeyValueService.flush} for every scope.
235
242
  */
236
243
  async #flushDirtyScopes(): Promise<void> {
237
- const anyDirty = [
244
+ const scopedServices = [
238
245
  ...this.#commandScopedKeyValueServices.values(),
239
246
  ...this.#serviceScopedKeyValueServices.values(),
240
- ].some((service) => service.isDirty());
241
- await this.#configurationServiceProvider.flushIfDirty(anyDirty);
247
+ ];
248
+ await this.#configurationServiceProvider.flushIfDirty(
249
+ scopedServices.some((service) => service.isDirty()),
250
+ );
251
+ for (const service of scopedServices) {
252
+ service.markClean();
253
+ }
242
254
  }
243
255
 
244
256
  public async initService(context: Context): Promise<void> {
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import type { ServiceInfo, ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
3
3
  import DefaultShutdownService from "./DefaultShutdownService.ts";
4
4
  import type { ShutdownService } from "@flowscripter/dynamic-cli-framework-api";
5
- import { SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
5
+ import { RunState, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
6
6
  import type { Context } from "@flowscripter/dynamic-cli-framework-api";
7
7
  import getLogger from "../../util/logger.ts";
8
8
  import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
@@ -37,7 +37,7 @@ export default class ShutdownServiceProvider implements ServiceProvider {
37
37
  shutdownState.interruptCount++;
38
38
  if (!shutdownState.longRunningMode || shutdownState.interruptCount >= 3) {
39
39
  ShutdownServiceProvider.shutdown().then(() => {
40
- process.exit(130);
40
+ process.exit(RunState.INTERRUPTED);
41
41
  });
42
42
  } else {
43
43
  shutdownState.shutdownRequested = true;
@@ -46,7 +46,7 @@ export default class ShutdownServiceProvider implements ServiceProvider {
46
46
 
47
47
  static onTerminate(): void {
48
48
  ShutdownServiceProvider.shutdown().then(() => {
49
- process.exit(143);
49
+ process.exit(RunState.TERMINATED);
50
50
  });
51
51
  }
52
52
 
@@ -108,6 +108,7 @@ export default class DefaultSpawnService implements SpawnService {
108
108
  try {
109
109
  proc = Bun.spawn(resolveForPlatform(command), {
110
110
  cwd: options.cwd,
111
+ env: options.env === undefined ? undefined : { ...process.env, ...options.env },
111
112
  stdin: mode === "inherit" ? "inherit" : "ignore",
112
113
  stdout: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
113
114
  stderr: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
@@ -1,8 +1,8 @@
1
1
  import process from "node:process";
2
- import { realpathSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import { mkdtemp, rename, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { basename, dirname, join } from "node:path";
6
6
  import type {
7
7
  CLIConfig,
8
8
  Context,
@@ -21,6 +21,7 @@ import {
21
21
  InstallMethod,
22
22
  KEY_VALUE_SERVICE_ID,
23
23
  PRINTER_SERVICE_ID,
24
+ RunState,
24
25
  SPAWN_SERVICE_ID,
25
26
  SupportedArch,
26
27
  SupportedOs,
@@ -85,6 +86,46 @@ export function describeUpgradeCheckResult(result: UpgradeCheckResult): string {
85
86
  : `Upgrade check result: ${JSON.stringify(result)}`;
86
87
  }
87
88
 
89
+ // Environment variable set on a process restarted after an automatic upgrade, holding the version
90
+ // which restarted it (see DefaultUpgradeService.restart() and restartedFromVersion).
91
+ const RESTARTED_FROM_VERSION_ENV_VAR = "DYNAMIC_CLI_FRAMEWORK_REEXEC_FROM";
92
+
93
+ // Exit codes from 129 upwards are reported for a child terminated by a signal (128 + signal number).
94
+ const SIGNAL_EXIT_CODE_BASE = 128;
95
+
96
+ const RUN_STATE_VALUES = new Set<number>(
97
+ Object.values(RunState).filter((value): value is number => typeof value === "number"),
98
+ );
99
+
100
+ /**
101
+ * Map the result of spawning a restarted CLI to the {@link RunState} it ended with.
102
+ *
103
+ * The child is the same framework CLI, so it normally exits with a {@link RunState} value, which is
104
+ * returned as is (this includes 130 for SIGINT and 143 for SIGTERM). Termination by any other
105
+ * signal, or a timeout, maps to {@link RunState.TERMINATED}, and any other exit code to
106
+ * {@link RunState.RUNTIME_ERROR}.
107
+ *
108
+ * @return the {@link RunState}, or `undefined` if the child could not be started.
109
+ */
110
+ export function toRunState(result: SpawnResult): RunState | undefined {
111
+ if (result.ok) {
112
+ return RunState.SUCCESS;
113
+ }
114
+ if ("timedOut" in result) {
115
+ return RunState.TERMINATED;
116
+ }
117
+ if (result.exitCode === undefined) {
118
+ return undefined;
119
+ }
120
+ if (RUN_STATE_VALUES.has(result.exitCode)) {
121
+ return result.exitCode as RunState;
122
+ }
123
+ if (result.exitCode > SIGNAL_EXIT_CODE_BASE) {
124
+ return RunState.TERMINATED;
125
+ }
126
+ return RunState.RUNTIME_ERROR;
127
+ }
128
+
88
129
  const OS_LABELS: Record<SupportedOs, string> = {
89
130
  [SupportedOs.LINUX]: "Linux",
90
131
  [SupportedOs.MACOS]: "MacOS",
@@ -96,10 +137,12 @@ export default class DefaultUpgradeService implements UpgradeService {
96
137
  #upgradeCheckPromise: Promise<UpgradeCheckResult> | undefined;
97
138
  readonly #config: UpgradeLocationsConfig;
98
139
  readonly #cliConfig: CLIConfig;
140
+ public readonly restartedFromVersion: string | undefined;
99
141
 
100
142
  public constructor(config: UpgradeLocationsConfig, cliConfig: CLIConfig) {
101
143
  this.#config = config;
102
144
  this.#cliConfig = cliConfig;
145
+ this.restartedFromVersion = process.env[RESTARTED_FROM_VERSION_ENV_VAR] || undefined;
103
146
  }
104
147
 
105
148
  public setContext(context: Context): void {
@@ -214,6 +257,67 @@ export default class DefaultUpgradeService implements UpgradeService {
214
257
  return cached as unknown as UpgradeCheckResult | undefined;
215
258
  }
216
259
 
260
+ /**
261
+ * Resolve the executable to restart into after a successful upgrade via `installMethod`.
262
+ *
263
+ * @return the executable path, or `undefined` if it cannot be resolved (e.g. for winget, or a
264
+ * homebrew install whose `opt` link is missing).
265
+ */
266
+ public resolveUpgradedExecutable(installMethod: InstallMethod): string | undefined {
267
+ switch (installMethod) {
268
+ case InstallMethod.GITHUB_RELEASE:
269
+ case InstallMethod.LINUX_SCRIPT:
270
+ return process.execPath;
271
+ case InstallMethod.HOMEBREW:
272
+ return this.#resolveHomebrewOptExecutable();
273
+ case InstallMethod.WINGET:
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ // The running executable lives in a version-specific Cellar directory, e.g.
279
+ // "<prefix>/Cellar/<formula>/<version>/bin/<exe>", while "<prefix>/opt/<formula>" links to the
280
+ // installed version, so the upgraded executable is "<prefix>/opt/<formula>/bin/<exe>".
281
+ #resolveHomebrewOptExecutable(): string | undefined {
282
+ if (!this.#config.homebrew) {
283
+ return undefined;
284
+ }
285
+ const { formula } = this.#config.homebrew;
286
+ const realExecutable = this.#resolveRealExecutable();
287
+ const cellarIndex = realExecutable.indexOf(`/Cellar/${formula}/`);
288
+ if (cellarIndex === -1) {
289
+ return undefined;
290
+ }
291
+ const prefix = realExecutable.slice(0, cellarIndex);
292
+ const executable = join(prefix, "opt", formula, "bin", basename(realExecutable));
293
+ return existsSync(executable) ? executable : undefined;
294
+ }
295
+
296
+ /**
297
+ * Run `executable` with `args` in place of this process, with terminal input and output
298
+ * inherited, and wait for it to exit. The child process sees the current version as its
299
+ * {@link restartedFromVersion}.
300
+ *
301
+ * @return the {@link RunState} the child ended with (see {@link toRunState}), or `undefined` if
302
+ * it could not be started.
303
+ */
304
+ public async restart(
305
+ executable: string,
306
+ args: ReadonlyArray<string>,
307
+ ): Promise<RunState | undefined> {
308
+ const spawnService = this.#spawnService;
309
+ if (!spawnService) {
310
+ return undefined;
311
+ }
312
+ const result = await spawnService.spawn([executable, ...args], {
313
+ mode: "inherit",
314
+ // Ctrl-C reaches the child directly from the terminal, so this process only notes it
315
+ longRunning: true,
316
+ env: { [RESTARTED_FROM_VERSION_ENV_VAR]: this.#cliConfig.version },
317
+ });
318
+ return toRunState(result);
319
+ }
320
+
217
321
  public detectOs(): SupportedOs | undefined {
218
322
  switch (process.platform) {
219
323
  case "linux":
@@ -430,14 +534,17 @@ export default class DefaultUpgradeService implements UpgradeService {
430
534
  // `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
431
535
  // StartupTask (see UpgradeServiceProvider) fast even though it runs to completion.
432
536
  #isRunningFromHomebrewCellar(formula: string): boolean {
433
- let realExecutable = process.execPath;
537
+ return this.#resolveRealExecutable().includes(`/Cellar/${formula}/`);
538
+ }
539
+
540
+ #resolveRealExecutable(): string {
434
541
  try {
435
- realExecutable = realpathSync(process.execPath);
542
+ return realpathSync(process.execPath);
436
543
  } catch {
437
544
  // process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
438
545
  // the unresolved path rather than treating that as "not installed".
546
+ return process.execPath;
439
547
  }
440
- return realExecutable.includes(`/Cellar/${formula}/`);
441
548
  }
442
549
 
443
550
  async #isHomebrewInstalled(): Promise<boolean> {