@flowscripter/dynamic-cli-framework 7.0.10 → 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 (52) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/src/cli/BaseCLI.d.ts.map +1 -1
  5. package/dist/src/cli/BaseCLI.js +24 -22
  6. package/dist/src/cli/BaseCLIFeatureOptions.d.ts +5 -0
  7. package/dist/src/cli/BaseCLIFeatureOptions.d.ts.map +1 -1
  8. package/dist/src/cli/DefaultRuntimeCLI.js +1 -1
  9. package/dist/src/runtime/lifecycle/priorities.d.ts +50 -0
  10. package/dist/src/runtime/lifecycle/priorities.d.ts.map +1 -0
  11. package/dist/src/runtime/lifecycle/priorities.js +49 -0
  12. package/dist/src/runtime/runner.d.ts +10 -1
  13. package/dist/src/runtime/runner.d.ts.map +1 -1
  14. package/dist/src/runtime/runner.js +7 -1
  15. package/dist/src/service/configuration/DefaultKeyValueService.d.ts +9 -1
  16. package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
  17. package/dist/src/service/configuration/DefaultKeyValueService.js +16 -1
  18. package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -1
  19. package/dist/src/service/configuration/KeyValueServiceProvider.js +15 -9
  20. package/dist/src/service/fetch/DefaultFetchService.d.ts.map +1 -1
  21. package/dist/src/service/fetch/DefaultFetchService.js +2 -1
  22. package/dist/src/service/printer/PrinterServiceProvider.d.ts.map +1 -1
  23. package/dist/src/service/printer/PrinterServiceProvider.js +2 -1
  24. package/dist/src/service/shutdown/ShutdownServiceProvider.js +3 -3
  25. package/dist/src/service/spawn/DefaultSpawnService.d.ts.map +1 -1
  26. package/dist/src/service/spawn/DefaultSpawnService.js +3 -1
  27. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts +30 -2
  28. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
  29. package/dist/src/service/upgrade/DefaultUpgradeService.js +99 -6
  30. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts +32 -2
  31. package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts.map +1 -1
  32. package/dist/src/service/upgrade/UpgradeServiceProvider.js +82 -14
  33. package/dist/src/startup/banner/bannerStartupTask.d.ts +5 -5
  34. package/dist/src/startup/banner/bannerStartupTask.d.ts.map +1 -1
  35. package/dist/src/startup/banner/bannerStartupTask.js +14 -10
  36. package/index.ts +20 -0
  37. package/package.json +2 -2
  38. package/src/cli/BaseCLI.ts +67 -21
  39. package/src/cli/BaseCLIFeatureOptions.ts +5 -0
  40. package/src/cli/DefaultRuntimeCLI.ts +1 -1
  41. package/src/runtime/lifecycle/priorities.ts +67 -0
  42. package/src/runtime/runner.ts +17 -2
  43. package/src/service/configuration/DefaultKeyValueService.ts +22 -1
  44. package/src/service/configuration/KeyValueServiceProvider.ts +20 -9
  45. package/src/service/fetch/DefaultFetchService.ts +2 -1
  46. package/src/service/printer/PrinterServiceProvider.ts +2 -1
  47. package/src/service/shutdown/ShutdownServiceProvider.ts +3 -3
  48. package/src/service/spawn/DefaultSpawnService.ts +3 -1
  49. package/src/service/upgrade/DefaultUpgradeService.ts +112 -5
  50. package/src/service/upgrade/UpgradeServiceProvider.ts +112 -20
  51. package/src/service/upgrade/command/UpgradeSubCommand.ts +2 -2
  52. package/src/startup/banner/bannerStartupTask.ts +16 -10
@@ -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> {
@@ -16,7 +16,7 @@ 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
+ import type { StartupTask, StartupTaskOutcome } from "@flowscripter/dynamic-cli-framework-api";
20
20
 
21
21
  const logger = getLogger("UpgradeServiceProvider");
22
22
 
@@ -40,16 +40,56 @@ export function createUpgradeCheckStartupTask(
40
40
  };
41
41
  }
42
42
 
43
+ /**
44
+ * Build the blocking {@link StartupTask} which offers to enable automatic upgrades and, when they
45
+ * are enabled, checks for and installs a newer version, optionally restarting into it (see
46
+ * {@link UpgradeServiceProvider.runAutoUpgrade}). It is registered separately from the provider's
47
+ * own init task so it can run at a lower priority, after consumer startup tasks such as the banner.
48
+ */
49
+ export function createAutoUpgradeStartupTask(
50
+ provider: UpgradeServiceProvider,
51
+ priority: number,
52
+ ): StartupTask {
53
+ return {
54
+ id: `${UPGRADE_SERVICE_ID}-auto-upgrade`,
55
+ priority,
56
+ mode: "blocking",
57
+ run: (context: Context) => provider.runAutoUpgrade(context),
58
+ };
59
+ }
60
+
43
61
  export default class UpgradeServiceProvider implements ServiceProvider {
44
62
  readonly serviceId: string = UPGRADE_SERVICE_ID;
45
63
  readonly servicePriority: number;
46
64
  readonly #config: UpgradeLocationsConfig;
47
65
  #upgradeService: DefaultUpgradeService | undefined;
48
66
  #cliConfig: CLIConfig | undefined;
67
+ #keyValueService: KeyValueService | undefined;
68
+ readonly #restartAfterAutoUpgrade: boolean;
69
+ #restartArgs: ReadonlyArray<string> = [];
49
70
 
50
- public constructor(servicePriority: number, config: UpgradeLocationsConfig) {
71
+ /**
72
+ * @param servicePriority the priority of the service.
73
+ * @param config the upgrade locations.
74
+ * @param restartAfterAutoUpgrade whether to run the upgraded executable, with the arguments from
75
+ * {@link setRestartArgs}, after a successful automatic upgrade and end the run with its result.
76
+ */
77
+ public constructor(
78
+ servicePriority: number,
79
+ config: UpgradeLocationsConfig,
80
+ restartAfterAutoUpgrade = false,
81
+ ) {
51
82
  this.servicePriority = servicePriority;
52
83
  this.#config = config;
84
+ this.#restartAfterAutoUpgrade = restartAfterAutoUpgrade;
85
+ }
86
+
87
+ /**
88
+ * Set the CLI arguments to run the upgraded executable with when restarting after an automatic
89
+ * upgrade. These are kept internal to the framework and never exposed to startup tasks.
90
+ */
91
+ public setRestartArgs(args: ReadonlyArray<string>): void {
92
+ this.#restartArgs = args;
53
93
  }
54
94
 
55
95
  public get upgradeService(): DefaultUpgradeService | undefined {
@@ -65,9 +105,8 @@ export default class UpgradeServiceProvider implements ServiceProvider {
65
105
  });
66
106
  }
67
107
 
68
- public async initService(context: Context): Promise<void> {
108
+ public initService(context: Context): Promise<void> {
69
109
  const upgradeService = this.#upgradeService!;
70
- const cliConfig = this.#cliConfig!;
71
110
 
72
111
  const spawnService = context.doesServiceExist(SPAWN_SERVICE_ID)
73
112
  ? (context.getServiceById(SPAWN_SERVICE_ID) as SpawnService)
@@ -81,10 +120,38 @@ export default class UpgradeServiceProvider implements ServiceProvider {
81
120
  if (fetchService === undefined) {
82
121
  logger.debug(() => "FetchService not available, upgrade version checks will be unavailable");
83
122
  }
84
- const keyValueService = context.doesServiceExist(KEY_VALUE_SERVICE_ID)
123
+ // the KeyValueService from this context is bound to the upgrade service's own scope, so it is
124
+ // kept for runAutoUpgrade(), whose task context is scoped to the task ID instead
125
+ this.#keyValueService = context.doesServiceExist(KEY_VALUE_SERVICE_ID)
85
126
  ? (context.getServiceById(KEY_VALUE_SERVICE_ID) as KeyValueService)
86
127
  : undefined;
87
128
  upgradeService.setContext(context);
129
+ return Promise.resolve();
130
+ }
131
+
132
+ /**
133
+ * Offer to enable automatic upgrades (storing the answer as `upgrade-status` in the upgrade
134
+ * service's scope) and, when they are enabled, check for and install a newer version. Must be
135
+ * called after {@link initService}.
136
+ *
137
+ * After a successful upgrade, when restarting is enabled, the upgraded executable is run with the
138
+ * arguments from {@link setRestartArgs} and its result is returned as an `exitRequest`. Does
139
+ * nothing in a process which was itself started by such a restart.
140
+ *
141
+ * @param context the startup task's context, used to look up the Prompter and Printer services.
142
+ */
143
+ public async runAutoUpgrade(context: Context): Promise<void | StartupTaskOutcome> {
144
+ const upgradeService = this.#upgradeService!;
145
+ const cliConfig = this.#cliConfig!;
146
+ const keyValueService = this.#keyValueService;
147
+
148
+ if (upgradeService.restartedFromVersion !== undefined) {
149
+ logger.debug(
150
+ () =>
151
+ `Restarted from version ${upgradeService.restartedFromVersion}, skipping auto-upgrade`,
152
+ );
153
+ return;
154
+ }
88
155
 
89
156
  if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
90
157
  logger.debug(() => "PrompterService not available, skipping auto-upgrade");
@@ -102,8 +169,7 @@ export default class UpgradeServiceProvider implements ServiceProvider {
102
169
  return;
103
170
  }
104
171
  if (status === "enabled") {
105
- await this.#checkAndUpgrade(context, upgradeService, cliConfig);
106
- return;
172
+ return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
107
173
  }
108
174
  }
109
175
 
@@ -149,14 +215,15 @@ export default class UpgradeServiceProvider implements ServiceProvider {
149
215
  }
150
216
 
151
217
  await keyValueService.set("upgrade-status", "enabled");
152
- await this.#checkAndUpgrade(context, upgradeService, cliConfig);
218
+ return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
153
219
  }
154
220
 
155
221
  async #checkAndUpgrade(
156
222
  context: Context,
157
223
  upgradeService: DefaultUpgradeService,
158
224
  cliConfig: CLIConfig,
159
- ): Promise<void> {
225
+ keyValueService: KeyValueService,
226
+ ): Promise<void | StartupTaskOutcome> {
160
227
  try {
161
228
  const checkResult = await upgradeService.getUpgradeCheckResult();
162
229
  if (checkResult.status === "failed") {
@@ -167,21 +234,46 @@ export default class UpgradeServiceProvider implements ServiceProvider {
167
234
  return;
168
235
  }
169
236
  const upgradeResult = await upgradeService.upgrade();
170
- if (!context.doesServiceExist(PRINTER_SERVICE_ID)) {
171
- return;
172
- }
173
- const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
174
- if (upgradeResult.ok) {
175
- await printerService.info(
176
- `${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
177
- Icon.SUCCESS,
178
- );
179
- } else {
180
- await printerService.error(
237
+ const printerService = context.doesServiceExist(PRINTER_SERVICE_ID)
238
+ ? (context.getServiceById(PRINTER_SERVICE_ID) as PrinterService)
239
+ : undefined;
240
+ if (!upgradeResult.ok) {
241
+ await printerService?.error(
181
242
  `Auto-upgrade failed: ${upgradeResult.error?.message ?? "unknown error"}\n`,
182
243
  Icon.FAILURE,
183
244
  );
245
+ return;
246
+ }
247
+ await printerService?.info(
248
+ `${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
249
+ Icon.SUCCESS,
250
+ );
251
+ if (!this.#restartAfterAutoUpgrade) {
252
+ return;
253
+ }
254
+
255
+ const executable = upgradeService.resolveUpgradedExecutable(checkResult.installMethod);
256
+ if (executable === undefined) {
257
+ await printerService?.info(
258
+ `Restart ${cliConfig.name} to use ${upgradeResult.newVersion}\n`,
259
+ Icon.INFORMATION,
260
+ );
261
+ return;
262
+ }
263
+
264
+ // the cache write from the background upgrade check lands before the flush, so the shutdown
265
+ // flush has nothing left to write over the restarted process's configuration
266
+ await upgradeService.refreshUpgradeCheckCache();
267
+ // written now so the restarted process sees the stored upgrade-status
268
+ await keyValueService.flush();
269
+ const runState = await upgradeService.restart(executable, this.#restartArgs);
270
+ if (runState !== undefined) {
271
+ return { exitRequest: { runState } };
184
272
  }
273
+ await printerService?.warn(
274
+ `${cliConfig.name} upgraded to ${upgradeResult.newVersion}; restart it to use the new version\n`,
275
+ Icon.ALERT,
276
+ );
185
277
  } catch (error) {
186
278
  logger.debug(() => `Auto-upgrade check failed: ${error}`);
187
279
  }
@@ -11,7 +11,7 @@ import {
11
11
  UPGRADE_SERVICE_ID,
12
12
  } from "@flowscripter/dynamic-cli-framework-api";
13
13
  import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
14
- import type DefaultUpgradeService from "../DefaultUpgradeService.ts";
14
+ import type { UpgradeService } from "@flowscripter/dynamic-cli-framework-api";
15
15
 
16
16
  export class UpgradeSubCommand implements SubCommand {
17
17
  readonly name = "upgrade";
@@ -38,7 +38,7 @@ export class UpgradeSubCommand implements SubCommand {
38
38
 
39
39
  public async execute(context: Context, argumentValues: Values): Promise<void> {
40
40
  const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
41
- const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as DefaultUpgradeService;
41
+ const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as UpgradeService;
42
42
  const cliName = context.cliConfig.name;
43
43
  const currentVersion = context.cliConfig.version;
44
44
 
@@ -7,21 +7,21 @@ import {
7
7
  } from "@flowscripter/dynamic-cli-framework-api";
8
8
  import type { AsciiBannerGeneratorService } from "@flowscripter/dynamic-cli-framework-api";
9
9
  import type { ConfigurationService } from "@flowscripter/dynamic-cli-framework-api";
10
- import DefaultUpgradeService from "../../service/upgrade/DefaultUpgradeService.ts";
10
+ import type { UpgradeService } from "@flowscripter/dynamic-cli-framework-api";
11
11
  import NoBannerCommand, { type BannerState } from "./command/NoBannerCommand.ts";
12
12
 
13
13
  export const BANNER_STARTUP_TASK_ID = "@flowscripter/dynamic-cli-framework/banner-startup-task";
14
14
 
15
15
  /**
16
- * Build the ascii banner {@link StartupTask}, replacing the old `BannerServiceProvider`.
16
+ * Build the ascii banner {@link StartupTask}.
17
17
  *
18
18
  * A consumer opts in by passing the result of this function in the `serviceProviders` array
19
19
  * argument of {@link launchMultiCommandCLI}/{@link launchSingleCommandCLI} (or via
20
- * `BaseCLI.addStartupTask`), the same way `new BannerServiceProvider(priority)` used to be passed
21
- * in that array - there's no default/automatic banner registration.
20
+ * `BaseCLI.addStartupTask`) - there's no default/automatic banner registration.
22
21
  *
23
- * @param priority the priority of the task (higher runs earlier - the same semantics
24
- * `BannerServiceProvider.servicePriority` had).
22
+ * @param priority the priority of the task (higher runs earlier). It must be above
23
+ * `AUTO_UPGRADE_STARTUP_TASK_PRIORITY` (10) for the banner to print before the automatic upgrade
24
+ * prompt and upgrade.
25
25
  * @param fontName an optional [FIGlet](http://www.figlet.org) font name to use for the banner,
26
26
  * defaults to "standard".
27
27
  */
@@ -41,6 +41,15 @@ export default function createBannerStartupTask(
41
41
  return;
42
42
  }
43
43
 
44
+ const upgradeService = context.doesServiceExist(UPGRADE_SERVICE_ID)
45
+ ? (context.getServiceById(UPGRADE_SERVICE_ID) as UpgradeService)
46
+ : undefined;
47
+ // a process restarted after an automatic upgrade follows the banner already printed by the
48
+ // process which restarted it
49
+ if (upgradeService?.restartedFromVersion !== undefined) {
50
+ return;
51
+ }
52
+
44
53
  const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
45
54
 
46
55
  const asciiBannerGeneratorService = context.getServiceById(
@@ -64,10 +73,7 @@ export default function createBannerStartupTask(
64
73
  // result yet) shows no upgrade-available suffix. The cached result is read through the
65
74
  // upgrade service because it is stored in that service's own KeyValueService scope, and
66
75
  // it is ignored if it was recorded by a different version (e.g. before an upgrade).
67
- const upgradeService = context.doesServiceExist(UPGRADE_SERVICE_ID)
68
- ? context.getServiceById(UPGRADE_SERVICE_ID)
69
- : undefined;
70
- if (upgradeService instanceof DefaultUpgradeService) {
76
+ if (upgradeService) {
71
77
  const result = await upgradeService.getCachedUpgradeCheckResult();
72
78
  if (
73
79
  result?.status === "checked" &&