@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
@@ -57,9 +57,28 @@ import ImagePrinterServiceProvider from "../service/imagePrinter/ImagePrinterSer
57
57
  import SpawnServiceProvider from "../service/spawn/SpawnServiceProvider.ts";
58
58
  import FetchServiceProvider from "../service/fetch/FetchServiceProvider.ts";
59
59
  import UpgradeServiceProvider, {
60
+ createAutoUpgradeStartupTask,
60
61
  createUpgradeCheckStartupTask,
61
62
  } from "../service/upgrade/UpgradeServiceProvider.ts";
62
63
  import PluginServiceProvider from "../service/plugin/PluginServiceProvider.ts";
64
+ import {
65
+ ARGUMENT_PROMPTER_SERVICE_PRIORITY,
66
+ AUTO_UPGRADE_STARTUP_TASK_PRIORITY,
67
+ COMPLETION_SERVICE_PRIORITY,
68
+ CONFIGURATION_SERVICE_PRIORITY,
69
+ FETCH_SERVICE_PRIORITY,
70
+ IMAGE_PRINTER_SERVICE_PRIORITY,
71
+ KEY_VALUE_SERVICE_PRIORITY,
72
+ PLUGIN_SERVICE_PRIORITY,
73
+ PRINTER_SERVICE_PRIORITY,
74
+ PROMPTER_SERVICE_PRIORITY,
75
+ SHUTDOWN_SERVICE_PRIORITY,
76
+ SPAWN_SERVICE_PRIORITY,
77
+ STARTUP_SERVICE_PRIORITY,
78
+ TABLE_GENERATOR_SERVICE_PRIORITY,
79
+ UPGRADE_CHECK_STARTUP_TASK_PRIORITY,
80
+ UPGRADE_SERVICE_PRIORITY,
81
+ } from "../runtime/lifecycle/priorities.ts";
63
82
  const logger = getLogger("BaseCLI");
64
83
 
65
84
  /**
@@ -152,6 +171,7 @@ export default class BaseCLI implements CLI {
152
171
  fetchServiceEnabled: false,
153
172
  upgradeServiceEnabled: false,
154
173
  upgradeLocationsConfig: { supportedPlatforms: [] },
174
+ restartAfterAutoUpgrade: false,
155
175
  pluginServiceEnabled: false,
156
176
  pluginServiceRemoteConfig: { name: "", registryUrl: "", packageJsonNamespace: "" },
157
177
  pluginServiceLocalConfig: { nodeModulesPath: "", packageJsonNamespace: "" },
@@ -177,9 +197,7 @@ export default class BaseCLI implements CLI {
177
197
  // create a context
178
198
  this.#context = new DefaultContext(this.#cliConfig);
179
199
 
180
- // priority 95 sits between shutdown (100) and configuration (90) - it has no ordering
181
- // dependency on anything since its own initService() is a no-op.
182
- this.#startupServiceProvider = new StartupServiceProvider(95);
200
+ this.#startupServiceProvider = new StartupServiceProvider(STARTUP_SERVICE_PRIORITY);
183
201
 
184
202
  this.#stdoutTerminal = stdoutTerminal;
185
203
  this.#printerService = new DefaultPrinterService(
@@ -251,10 +269,12 @@ export default class BaseCLI implements CLI {
251
269
  }
252
270
 
253
271
  // create and add core services
254
- this.addServiceProvider(new ShutdownServiceProvider(100));
272
+ this.addServiceProvider(new ShutdownServiceProvider(SHUTDOWN_SERVICE_PRIORITY));
255
273
  this.addServiceProvider(this.#startupServiceProvider);
256
- this.addServiceProvider(new PrinterServiceProvider(80, this.#printerService));
257
- this.addServiceProvider(new TableGeneratorServiceProvider(70));
274
+ this.addServiceProvider(
275
+ new PrinterServiceProvider(PRINTER_SERVICE_PRIORITY, this.#printerService),
276
+ );
277
+ this.addServiceProvider(new TableGeneratorServiceProvider(TABLE_GENERATOR_SERVICE_PRIORITY));
258
278
 
259
279
  const canPrompt = this.#keyReader !== undefined && this.#stderrTerminal.isTty();
260
280
 
@@ -271,7 +291,9 @@ export default class BaseCLI implements CLI {
271
291
  this.#printerService,
272
292
  );
273
293
  prompterService.promptEnabled = this.#options.promptingEnabled;
274
- this.addServiceProvider(new PrompterServiceProvider(75, prompterService));
294
+ this.addServiceProvider(
295
+ new PrompterServiceProvider(PROMPTER_SERVICE_PRIORITY, prompterService),
296
+ );
275
297
  } else {
276
298
  logger.debug(
277
299
  () =>
@@ -284,7 +306,12 @@ export default class BaseCLI implements CLI {
284
306
  if (this.#options.argumentPrompterServiceEnabled) {
285
307
  if (prompterService !== undefined) {
286
308
  const argumentPrompterService = new DefaultArgumentPrompterService(prompterService);
287
- this.addServiceProvider(new ArgumentPrompterServiceProvider(65, argumentPrompterService));
309
+ this.addServiceProvider(
310
+ new ArgumentPrompterServiceProvider(
311
+ ARGUMENT_PROMPTER_SERVICE_PRIORITY,
312
+ argumentPrompterService,
313
+ ),
314
+ );
288
315
  } else {
289
316
  logger.debug(
290
317
  "Skipping ArgumentPrompterServiceProvider: argumentPrompterServiceEnabled is true but prompting is unavailable",
@@ -294,7 +321,9 @@ export default class BaseCLI implements CLI {
294
321
 
295
322
  if (this.#options.imagePrinterServiceEnabled) {
296
323
  if (this.#stdoutTerminal.isTty()) {
297
- this.addServiceProvider(new ImagePrinterServiceProvider(55, this.#stdoutTerminal));
324
+ this.addServiceProvider(
325
+ new ImagePrinterServiceProvider(IMAGE_PRINTER_SERVICE_PRIORITY, this.#stdoutTerminal),
326
+ );
298
327
  } else {
299
328
  logger.debug(
300
329
  "Skipping ImagePrinterServiceProvider: imagePrinterServiceEnabled is true but stdout is not a TTY",
@@ -305,32 +334,39 @@ export default class BaseCLI implements CLI {
305
334
  // The plugin service routes package manager output through SpawnService so that it is
306
335
  // quoted, marked and cleared on success; enabling plugins therefore also enables spawning.
307
336
  if (this.#options.spawnServiceEnabled || this.#options.pluginServiceEnabled) {
308
- this.addServiceProvider(new SpawnServiceProvider(58));
337
+ this.addServiceProvider(new SpawnServiceProvider(SPAWN_SERVICE_PRIORITY));
309
338
  }
310
339
 
311
340
  if (this.#options.fetchServiceEnabled) {
312
- this.addServiceProvider(new FetchServiceProvider(57));
341
+ this.addServiceProvider(new FetchServiceProvider(FETCH_SERVICE_PRIORITY));
313
342
  }
314
343
 
315
344
  if (this.#options.completionServiceEnabled) {
316
345
  const completionService = new DefaultCompletionService();
317
346
  this.addServiceProvider(
318
- new CompletionServiceProvider(5, completionService, this.#commandRegistry),
347
+ new CompletionServiceProvider(
348
+ COMPLETION_SERVICE_PRIORITY,
349
+ completionService,
350
+ this.#commandRegistry,
351
+ ),
319
352
  );
320
353
  }
321
354
 
322
355
  let upgradeServiceProvider: UpgradeServiceProvider | undefined;
323
356
  if (this.#options.upgradeServiceEnabled) {
324
- // 56 runs after Spawn(58)/Fetch(57) - whose dependencies it needs
325
- // but before the consumer-configured Banner/Plugin(50) priority band.
326
- upgradeServiceProvider = new UpgradeServiceProvider(56, this.#options.upgradeLocationsConfig);
357
+ upgradeServiceProvider = new UpgradeServiceProvider(
358
+ UPGRADE_SERVICE_PRIORITY,
359
+ this.#options.upgradeLocationsConfig,
360
+ this.#options.restartAfterAutoUpgrade,
361
+ );
362
+ upgradeServiceProvider.setRestartArgs(args);
327
363
  this.addServiceProvider(upgradeServiceProvider);
328
364
  }
329
365
 
330
366
  if (this.#options.pluginServiceEnabled) {
331
367
  this.addServiceProvider(
332
368
  new PluginServiceProvider(
333
- 50,
369
+ PLUGIN_SERVICE_PRIORITY,
334
370
  this.#options.pluginServiceRemoteConfig,
335
371
  this.#options.pluginServiceLocalConfig,
336
372
  this.#commandRegistry,
@@ -340,16 +376,14 @@ export default class BaseCLI implements CLI {
340
376
  }
341
377
 
342
378
  const configurationServiceProvider = new ConfigurationServiceProvider(
343
- 90,
379
+ CONFIGURATION_SERVICE_PRIORITY,
344
380
  this.#options.envVarsSupportEnabled,
345
381
  this.#options.configFileSupportEnabled,
346
382
  this.#options.secretServiceEnabled,
347
383
  );
348
384
  this.addServiceProvider(configurationServiceProvider);
349
- // priority 89 sits just below ConfigurationServiceProvider's 90 - its config-file read
350
- // happens in its own initService(), before KeyValueServiceProvider's initService() runs.
351
385
  const keyValueServiceProvider = new KeyValueServiceProvider(
352
- 89,
386
+ KEY_VALUE_SERVICE_PRIORITY,
353
387
  configurationServiceProvider,
354
388
  this.#options.keyValueServiceEnabled,
355
389
  this.#options.secretServiceEnabled,
@@ -369,7 +403,15 @@ export default class BaseCLI implements CLI {
369
403
  }
370
404
 
371
405
  if (upgradeServiceProvider?.upgradeService) {
372
- this.addStartupTask(createUpgradeCheckStartupTask(upgradeServiceProvider.upgradeService, 56));
406
+ this.addStartupTask(
407
+ createUpgradeCheckStartupTask(
408
+ upgradeServiceProvider.upgradeService,
409
+ UPGRADE_CHECK_STARTUP_TASK_PRIORITY,
410
+ ),
411
+ );
412
+ this.addStartupTask(
413
+ createAutoUpgradeStartupTask(upgradeServiceProvider, AUTO_UPGRADE_STARTUP_TASK_PRIORITY),
414
+ );
373
415
  }
374
416
 
375
417
  // directly-registered StartupTasks (e.g. the banner task) aren't backed by a ServiceProvider,
@@ -435,6 +477,10 @@ export default class BaseCLI implements CLI {
435
477
  defaultCommand,
436
478
  this.#startupServiceProvider.startupService,
437
479
  );
480
+ // a startup task ended the run, having already handled its outcome itself
481
+ if (runResult.exitRequested) {
482
+ return { runState: runResult.runState };
483
+ }
438
484
  // then handle the result...
439
485
  if (runResult.runState === RunState.NO_COMMAND) {
440
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
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Priorities of the framework's built-in service providers, startup tasks and shutdown tasks.
3
+ * Higher values run earlier (see {@link sortByPriority}). Service provider priorities also order
4
+ * their `initService()` calls within the startup sequence: when a provider and a directly
5
+ * registered startup task share a priority, the provider runs first.
6
+ *
7
+ * Consumer service providers and startup tasks (e.g. the banner task) choose their own
8
+ * priorities relative to these.
9
+ */
10
+
11
+ /** Shutdown service provider: first, so signal handling is in place before anything else. */
12
+ export const SHUTDOWN_SERVICE_PRIORITY = 100;
13
+
14
+ /** Startup service provider: no ordering dependency, as its `initService()` is a no-op. */
15
+ export const STARTUP_SERVICE_PRIORITY = 95;
16
+
17
+ /** Configuration service provider: reads the config file in its `initService()`. */
18
+ export const CONFIGURATION_SERVICE_PRIORITY = 90;
19
+
20
+ /** Key-value service provider: just below configuration, whose config-file read it depends on. */
21
+ export const KEY_VALUE_SERVICE_PRIORITY = 89;
22
+
23
+ /** Printer service provider. */
24
+ export const PRINTER_SERVICE_PRIORITY = 80;
25
+
26
+ /** Prompter service provider. */
27
+ export const PROMPTER_SERVICE_PRIORITY = 75;
28
+
29
+ /** Table generator service provider. */
30
+ export const TABLE_GENERATOR_SERVICE_PRIORITY = 70;
31
+
32
+ /** Argument prompter service provider: below the prompter it uses. */
33
+ export const ARGUMENT_PROMPTER_SERVICE_PRIORITY = 65;
34
+
35
+ /** Spawn service provider. */
36
+ export const SPAWN_SERVICE_PRIORITY = 58;
37
+
38
+ /** Fetch service provider. */
39
+ export const FETCH_SERVICE_PRIORITY = 57;
40
+
41
+ /** Upgrade service provider: below the spawn and fetch services it uses. */
42
+ export const UPGRADE_SERVICE_PRIORITY = 56;
43
+
44
+ /** Background startup task which refreshes the cached upgrade check result. */
45
+ export const UPGRADE_CHECK_STARTUP_TASK_PRIORITY = 56;
46
+
47
+ /** Image printer service provider. */
48
+ export const IMAGE_PRINTER_SERVICE_PRIORITY = 55;
49
+
50
+ /** Plugin service provider. */
51
+ export const PLUGIN_SERVICE_PRIORITY = 50;
52
+
53
+ /**
54
+ * Startup task which prompts to enable automatic upgrades and performs them. It runs below
55
+ * consumer startup tasks such as the banner (typically 45 or more) and above the completion
56
+ * prompt.
57
+ */
58
+ export const AUTO_UPGRADE_STARTUP_TASK_PRIORITY = 10;
59
+
60
+ /** Completion service provider, whose `initService()` prompts to enable shell completion. */
61
+ export const COMPLETION_SERVICE_PRIORITY = 5;
62
+
63
+ /** Shutdown tasks which clean up in-flight work (spinners, progress bars, spawns, fetches). */
64
+ export const CLEANUP_SHUTDOWN_TASK_PRIORITY = 0;
65
+
66
+ /** Shutdown task which flushes key-value data to the config file, after all other cleanup. */
67
+ export const KEY_VALUE_FLUSH_SHUTDOWN_TASK_PRIORITY = -100;
@@ -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) {
@@ -12,6 +12,7 @@ import DefaultSecretService from "./DefaultSecretService.ts";
12
12
  import type ConfigurationServiceProvider from "./ConfigurationServiceProvider.ts";
13
13
  import type { KeyValueServiceScopeType } from "./ConfigurationServiceProvider.ts";
14
14
  import type DefaultContext from "../../runtime/DefaultContext.ts";
15
+ import { KEY_VALUE_FLUSH_SHUTDOWN_TASK_PRIORITY } from "../../runtime/lifecycle/priorities.ts";
15
16
 
16
17
  /**
17
18
  * Registered under {@link KEY_VALUE_SERVICE_ID} in the plain, undecorated {@link Context} purely
@@ -37,6 +38,10 @@ class UnreachableKeyValueService implements KeyValueService {
37
38
  return Promise.reject(new Error(UnreachableKeyValueService.#message));
38
39
  }
39
40
 
41
+ flush(): Promise<void> {
42
+ return Promise.reject(new Error(UnreachableKeyValueService.#message));
43
+ }
44
+
40
45
  static readonly #message =
41
46
  "KeyValueService accessed without a resolved scope - this should be unreachable";
42
47
  }
@@ -188,7 +193,9 @@ export default class KeyValueServiceProvider implements ServiceProvider {
188
193
  const secretService = this.secretServiceEnabled
189
194
  ? new DefaultSecretService(this.#cliConfigName!, `${scopeType}_${scopeKey}`)
190
195
  : undefined;
191
- const scopedService = new DefaultKeyValueService(keyValueData, secretService);
196
+ const scopedService = new DefaultKeyValueService(keyValueData, secretService, () =>
197
+ this.#flushDirtyScopes(),
198
+ );
192
199
  scopedServices.set(scopeKey, scopedService);
193
200
  return scopedService;
194
201
  }
@@ -229,15 +236,21 @@ export default class KeyValueServiceProvider implements ServiceProvider {
229
236
 
230
237
  /**
231
238
  * Flush every scoped KeyValueService's data to the configuration file, if any of them report
232
- * themselves dirty. Registered as a low-priority ShutdownTask (see {@link initService}) so it
233
- * 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.
234
242
  */
235
243
  async #flushDirtyScopes(): Promise<void> {
236
- const anyDirty = [
244
+ const scopedServices = [
237
245
  ...this.#commandScopedKeyValueServices.values(),
238
246
  ...this.#serviceScopedKeyValueServices.values(),
239
- ].some((service) => service.isDirty());
240
- 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
+ }
241
254
  }
242
255
 
243
256
  public async initService(context: Context): Promise<void> {
@@ -245,9 +258,7 @@ export default class KeyValueServiceProvider implements ServiceProvider {
245
258
  const shutdownService = context.getServiceById(SHUTDOWN_SERVICE_ID) as ShutdownService;
246
259
  shutdownService.registerTask({
247
260
  id: `${KEY_VALUE_SERVICE_ID}-flush`,
248
- // low priority - shutdown tasks run in descending priority order, so this runs after
249
- // other shutdown cleanup (which defaults to priority 0).
250
- priority: -100,
261
+ priority: KEY_VALUE_FLUSH_SHUTDOWN_TASK_PRIORITY,
251
262
  run: () => this.#flushDirtyScopes(),
252
263
  });
253
264
  }
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@flowscripter/dynamic-cli-framework-api";
7
7
  import { SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
8
8
  import getLogger from "../../util/logger.ts";
9
+ import { CLEANUP_SHUTDOWN_TASK_PRIORITY } from "../../runtime/lifecycle/priorities.ts";
9
10
 
10
11
  const logger = getLogger("DefaultFetchService");
11
12
 
@@ -38,7 +39,7 @@ export default class DefaultFetchService implements FetchService {
38
39
  shutdownService.enterLongRunningMode();
39
40
  shutdownService.registerTask({
40
41
  id: `fetch:${input.toString()}`,
41
- priority: 0,
42
+ priority: CLEANUP_SHUTDOWN_TASK_PRIORITY,
42
43
  run: async () => {
43
44
  if (settled) {
44
45
  return;
@@ -9,6 +9,7 @@ import NoHyperlinksCommand from "./command/NoHyperlinksCommand.ts";
9
9
  import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
10
10
  import type { ShutdownService } from "@flowscripter/dynamic-cli-framework-api";
11
11
  import { SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
12
+ import { CLEANUP_SHUTDOWN_TASK_PRIORITY } from "../../runtime/lifecycle/priorities.ts";
12
13
 
13
14
  /**
14
15
  * Provides a {@link PrinterService}.
@@ -45,7 +46,7 @@ export default class PrinterServiceProvider implements ServiceProvider {
45
46
  const shutdownService = context.getServiceById(SHUTDOWN_SERVICE_ID) as ShutdownService;
46
47
  shutdownService.registerTask({
47
48
  id: PRINTER_SERVICE_ID,
48
- priority: 0,
49
+ priority: CLEANUP_SHUTDOWN_TASK_PRIORITY,
49
50
  run: async () => {
50
51
  await this.printerService.hideSpinner();
51
52
  await this.printerService.hideAllProgressBars();
@@ -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
 
@@ -9,6 +9,7 @@ import type {
9
9
  } from "@flowscripter/dynamic-cli-framework-api";
10
10
  import { PRINTER_SERVICE_ID, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
11
11
  import getLogger from "../../util/logger.ts";
12
+ import { CLEANUP_SHUTDOWN_TASK_PRIORITY } from "../../runtime/lifecycle/priorities.ts";
12
13
 
13
14
  const logger = getLogger("DefaultSpawnService");
14
15
 
@@ -107,6 +108,7 @@ export default class DefaultSpawnService implements SpawnService {
107
108
  try {
108
109
  proc = Bun.spawn(resolveForPlatform(command), {
109
110
  cwd: options.cwd,
111
+ env: options.env === undefined ? undefined : { ...process.env, ...options.env },
110
112
  stdin: mode === "inherit" ? "inherit" : "ignore",
111
113
  stdout: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
112
114
  stderr: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
@@ -124,7 +126,7 @@ export default class DefaultSpawnService implements SpawnService {
124
126
 
125
127
  shutdownService.registerTask({
126
128
  id: `spawn:${command.join(" ")}`,
127
- priority: 0,
129
+ priority: CLEANUP_SHUTDOWN_TASK_PRIORITY,
128
130
  run: async () => {
129
131
  if (settled) {
130
132
  return;