@flowscripter/dynamic-cli-framework 2.3.3 → 2.4.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.
@@ -12,7 +12,7 @@ export default class VersionCommand {
12
12
  let line = context.cliConfig.version;
13
13
  if (context.doesServiceExist(UPGRADE_SERVICE_ID)) {
14
14
  const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID);
15
- const result = await upgradeService.checkForUpgrade().catch(() => undefined);
15
+ const result = await upgradeService.getUpgradeCheckResult().catch(() => undefined);
16
16
  if (result?.updateAvailable) {
17
17
  line += ` (${result.latestVersion} available, run '${context.cliConfig.name} upgrade')`;
18
18
  }
@@ -52,7 +52,7 @@ export default class BannerServiceProvider {
52
52
  }
53
53
  if (context.doesServiceExist(UPGRADE_SERVICE_ID)) {
54
54
  const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID);
55
- const result = await upgradeService.checkForUpgrade().catch(() => undefined);
55
+ const result = await upgradeService.getUpgradeCheckResult().catch(() => undefined);
56
56
  if (result?.updateAvailable) {
57
57
  await printerService.info(` ${printerService.secondary(`(${result.latestVersion} available, run '${cliConfig.name} upgrade')`)}\n`);
58
58
  }
@@ -1 +1 @@
1
- {"version":3,"file":"SpawnInterfaceAdapter.d.ts","sourceRoot":"","sources":["../../../../src/service/plugin/SpawnInterfaceAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,wCAAwC,CAAC;AAE1F,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wBAAwB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAOhE,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,EAC9B,OAAO,GAAE,4BAAiC;IAQ/B,KAAK,CAChB,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,EAC9B,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GACvB,OAAO,CAAC,WAAW,CAAC;CA4BxB"}
1
+ {"version":3,"file":"SpawnInterfaceAdapter.d.ts","sourceRoot":"","sources":["../../../../src/service/plugin/SpawnInterfaceAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,wCAAwC,CAAC;AAE1F,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wBAAwB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAOhE,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,EAC9B,OAAO,GAAE,4BAAiC;IAQ/B,KAAK,CAChB,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,EAC9B,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GACvB,OAAO,CAAC,WAAW,CAAC;CA+BxB"}
@@ -33,8 +33,11 @@ export default class SpawnInterfaceAdapter {
33
33
  this.#printerService.endQuote();
34
34
  this.#printerService.endMark();
35
35
  await this.#printerService.clearMarked(this.#markMinimumDisplayTimeMs);
36
- return result.ok
37
- ? { ok: true, exitCode: result.exitCode }
36
+ if (result.ok) {
37
+ return { ok: true, exitCode: result.exitCode };
38
+ }
39
+ return "timedOut" in result
40
+ ? { ok: false, error: new Error("Command timed out") }
38
41
  : { ok: false, exitCode: result.exitCode, error: result.error };
39
42
  }
40
43
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DefaultSpawnService.d.ts","sourceRoot":"","sources":["../../../../src/service/spawn/DefaultSpawnService.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,WAAW,EACX,YAAY,EACb,MAAM,yCAAyC,CAAC;AAOjD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAa3E;AAED,MAAM,CAAC,OAAO,OAAO,mBAAoB,YAAW,YAAY;;IAIvD,eAAe,CAAC,cAAc,EAAE,cAAc,EAAE,eAAe,EAAE,eAAe,GAAG,IAAI;IAkCjF,KAAK,CAChB,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,EAC9B,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,WAAW,CAAC;CAiExB"}
1
+ {"version":3,"file":"DefaultSpawnService.d.ts","sourceRoot":"","sources":["../../../../src/service/spawn/DefaultSpawnService.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,WAAW,EACX,YAAY,EACb,MAAM,yCAAyC,CAAC;AAOjD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAa3E;AAED,MAAM,CAAC,OAAO,OAAO,mBAAoB,YAAW,YAAY;;IAIvD,eAAe,CAAC,cAAc,EAAE,cAAc,EAAE,eAAe,EAAE,eAAe,GAAG,IAAI;IAkDjF,KAAK,CAChB,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,EAC9B,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,WAAW,CAAC;CA8ExB"}
@@ -28,6 +28,18 @@ export default class DefaultSpawnService {
28
28
  this.#printerService = printerService;
29
29
  this.#shutdownService = shutdownService;
30
30
  }
31
+ async #terminate(proc, command) {
32
+ logger.debug(() => `Sending SIGTERM to spawned command '${command.join(" ")}'`);
33
+ proc.kill("SIGTERM");
34
+ const exitedInTime = await Promise.race([
35
+ proc.exited.then(() => true),
36
+ new Promise((resolve) => setTimeout(() => resolve(false), SHUTDOWN_GRACE_PERIOD_MS)),
37
+ ]);
38
+ if (!exitedInTime) {
39
+ logger.debug(() => `Sending SIGKILL to spawned command '${command.join(" ")}'`);
40
+ proc.kill("SIGKILL");
41
+ }
42
+ }
31
43
  async #pipeLines(stream, streamName, onOutput) {
32
44
  if (typeof stream !== "object" || stream === null) {
33
45
  return;
@@ -85,16 +97,7 @@ export default class DefaultSpawnService {
85
97
  if (settled) {
86
98
  return;
87
99
  }
88
- logger.debug(() => `Sending SIGTERM to spawned command '${command.join(" ")}'`);
89
- proc.kill("SIGTERM");
90
- await Promise.race([
91
- proc.exited.then(() => { }),
92
- new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_PERIOD_MS)),
93
- ]);
94
- if (!settled) {
95
- logger.debug(() => `Sending SIGKILL to spawned command '${command.join(" ")}'`);
96
- proc.kill("SIGKILL");
97
- }
100
+ await this.#terminate(proc, command);
98
101
  });
99
102
  if (stdio === "wrapped" && options.onOutput) {
100
103
  const onOutput = options.onOutput;
@@ -102,6 +105,25 @@ export default class DefaultSpawnService {
102
105
  void this.#pipeLines(proc.stderr, "stderr", onOutput);
103
106
  }
104
107
  try {
108
+ if (options.timeoutMs === undefined) {
109
+ const exitCode = await proc.exited;
110
+ settled = true;
111
+ return exitCode === 0 ? { ok: true, exitCode } : { ok: false, exitCode };
112
+ }
113
+ let timeoutHandle;
114
+ const timedOut = await Promise.race([
115
+ proc.exited.then(() => false),
116
+ new Promise((resolve) => {
117
+ timeoutHandle = setTimeout(() => resolve(true), options.timeoutMs);
118
+ }),
119
+ ]);
120
+ clearTimeout(timeoutHandle);
121
+ if (timedOut) {
122
+ logger.debug(() => `Command '${command.join(" ")}' timed out after ${options.timeoutMs}ms`);
123
+ await this.#terminate(proc, command);
124
+ settled = true;
125
+ return { ok: false, timedOut: true };
126
+ }
105
127
  const exitCode = await proc.exited;
106
128
  settled = true;
107
129
  return exitCode === 0 ? { ok: true, exitCode } : { ok: false, exitCode };
@@ -1,10 +1,12 @@
1
1
  import type { CLIConfig, ShutdownService, SpawnService, UpgradeCheckResult, UpgradeResult } from "@flowscripter/dynamic-cli-framework-api";
2
2
  import { InstallMethod, SupportedArch, SupportedOs, type UpgradeService } from "@flowscripter/dynamic-cli-framework-api";
3
3
  import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
4
+ export declare const VERSION_CHECK_TIMEOUT_MS = 250;
4
5
  export default class DefaultUpgradeService implements UpgradeService {
5
6
  #private;
6
7
  constructor(config: UpgradeLocationsConfig, cliConfig: CLIConfig);
7
8
  setDependencies(spawnService: SpawnService, shutdownService?: ShutdownService): void;
9
+ getUpgradeCheckResult(waitForResult?: boolean): Promise<UpgradeCheckResult | undefined>;
8
10
  detectOs(): SupportedOs | undefined;
9
11
  detectArch(): SupportedArch | undefined;
10
12
  detectInstallMethod(os: SupportedOs): Promise<InstallMethod | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACd,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,aAAa,EACb,aAAa,EACb,WAAW,EACX,KAAK,cAAc,EACpB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AA0B1E,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAM/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CAAC,YAAY,EAAE,YAAY,EAAE,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI;IAKpF,QAAQ,IAAI,WAAW,GAAG,SAAS;IAanC,UAAU,IAAI,aAAa,GAAG,SAAS;IAWjC,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAgBxE,eAAe,CAC1B,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAmC7B,OAAO,CAClB,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,aAAa,CAAC;CAiR1B"}
1
+ {"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,eAAe,EAEf,YAAY,EACZ,kBAAkB,EAClB,aAAa,EACd,MAAM,yCAAyC,CAAC;AACjD,OAAO,EACL,aAAa,EACb,aAAa,EACb,WAAW,EACX,KAAK,cAAc,EACpB,MAAM,yCAAyC,CAAC;AAEjD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAO1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAc5C,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAO/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CAAC,YAAY,EAAE,YAAY,EAAE,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI;IAKpF,qBAAqB,CAAC,aAAa,UAAQ,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAerF,QAAQ,IAAI,WAAW,GAAG,SAAS;IAanC,UAAU,IAAI,aAAa,GAAG,SAAS;IAWjC,mBAAmB,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAgBxE,eAAe,CAC1B,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAmC7B,OAAO,CAClB,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,aAAa,CAAC;CAyQ1B"}
@@ -7,17 +7,12 @@ import semver from "semver";
7
7
  import getLogger from "../../util/logger.js";
8
8
  const logger = getLogger("DefaultUpgradeService");
9
9
  // checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
10
- // so its version-check network calls must never be allowed to stall CLI startup.
11
- const VERSION_CHECK_TIMEOUT_MS = 250;
12
- // SpawnService has no built-in cancellation, so a stalled `brew`/`winget` invocation (e.g. winget's
13
- // first-ever run on a machine prompting to accept Microsoft Store terms) can otherwise hang
14
- // checkForUpgrade() indefinitely. Racing against this bounds how long we wait for a result; the
15
- // underlying process is not killed, but the CLI stops blocking on it.
16
- async function withSpawnTimeout(spawnPromise, fallback) {
17
- return Promise.race([
18
- spawnPromise,
19
- new Promise((resolve) => setTimeout(() => resolve(fallback), VERSION_CHECK_TIMEOUT_MS)),
20
- ]);
10
+ // so its version-check network/spawn calls must never be allowed to stall CLI startup.
11
+ export const VERSION_CHECK_TIMEOUT_MS = 250;
12
+ function describeSpawnFailure(result) {
13
+ return "timedOut" in result
14
+ ? "timed out"
15
+ : (result.error?.message ?? `exit code ${result.exitCode}`);
21
16
  }
22
17
  const OS_LABELS = {
23
18
  [SupportedOs.LINUX]: "Linux",
@@ -27,6 +22,7 @@ const OS_LABELS = {
27
22
  export default class DefaultUpgradeService {
28
23
  #spawnService;
29
24
  #shutdownService;
25
+ #upgradeCheckPromise;
30
26
  #config;
31
27
  #cliConfig;
32
28
  constructor(config, cliConfig) {
@@ -37,6 +33,18 @@ export default class DefaultUpgradeService {
37
33
  this.#spawnService = spawnService;
38
34
  this.#shutdownService = shutdownService;
39
35
  }
36
+ getUpgradeCheckResult(waitForResult = false) {
37
+ if (!this.#upgradeCheckPromise) {
38
+ this.#upgradeCheckPromise = this.checkForUpgrade();
39
+ }
40
+ if (waitForResult) {
41
+ return this.#upgradeCheckPromise;
42
+ }
43
+ return Promise.race([
44
+ this.#upgradeCheckPromise,
45
+ new Promise((resolve) => setTimeout(() => resolve(undefined), VERSION_CHECK_TIMEOUT_MS)),
46
+ ]);
47
+ }
40
48
  detectOs() {
41
49
  switch (process.platform) {
42
50
  case "linux":
@@ -106,7 +114,10 @@ export default class DefaultUpgradeService {
106
114
  }
107
115
  async upgrade(osOverride, archOverride, installMethodOverride) {
108
116
  const oldVersion = this.#cliConfig.version;
109
- const checkResult = await this.checkForUpgrade(osOverride, archOverride, installMethodOverride);
117
+ const hasOverride = osOverride !== undefined || archOverride !== undefined || installMethodOverride !== undefined;
118
+ const checkResult = hasOverride
119
+ ? await this.checkForUpgrade(osOverride, archOverride, installMethodOverride)
120
+ : await this.getUpgradeCheckResult(true);
110
121
  if (!checkResult) {
111
122
  return {
112
123
  ok: false,
@@ -145,20 +156,14 @@ export default class DefaultUpgradeService {
145
156
  if (!this.#spawnService || !this.#config.homebrew) {
146
157
  return false;
147
158
  }
148
- const result = await withSpawnTimeout(this.#spawnService.spawn(["brew", "list", "--versions", this.#config.homebrew.formula], {
149
- stdio: "wrapped",
150
- longRunning: false,
151
- }), { ok: false });
159
+ const result = await this.#spawnService.spawn(["brew", "list", "--versions", this.#config.homebrew.formula], { stdio: "wrapped", longRunning: false, timeoutMs: VERSION_CHECK_TIMEOUT_MS });
152
160
  return result.ok;
153
161
  }
154
162
  async #isWingetInstalled() {
155
163
  if (!this.#spawnService || !this.#config.winget) {
156
164
  return false;
157
165
  }
158
- const result = await withSpawnTimeout(this.#spawnService.spawn(["winget", "list", "--id", this.#config.winget.packageId], {
159
- stdio: "wrapped",
160
- longRunning: false,
161
- }), { ok: false });
166
+ const result = await this.#spawnService.spawn(["winget", "list", "--id", this.#config.winget.packageId], { stdio: "wrapped", longRunning: false, timeoutMs: VERSION_CHECK_TIMEOUT_MS });
162
167
  return result.ok;
163
168
  }
164
169
  #isLinuxScriptInstall() {
@@ -222,11 +227,12 @@ export default class DefaultUpgradeService {
222
227
  return undefined;
223
228
  }
224
229
  const lines = [];
225
- const result = await withSpawnTimeout(this.#spawnService.spawn(["winget", "show", "--id", this.#config.winget.packageId], {
230
+ const result = await this.#spawnService.spawn(["winget", "show", "--id", this.#config.winget.packageId], {
226
231
  stdio: "wrapped",
227
232
  longRunning: false,
233
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
228
234
  onOutput: (line) => lines.push(line),
229
- }), { ok: false });
235
+ });
230
236
  if (!result.ok) {
231
237
  return undefined;
232
238
  }
@@ -242,14 +248,14 @@ export default class DefaultUpgradeService {
242
248
  const { scriptUrl } = this.#config.linuxScript;
243
249
  const result = await this.#spawnService.spawn(["sh", "-c", `curl -fsSL ${scriptUrl} | sh`]);
244
250
  if (!result.ok) {
245
- throw new Error(`Install script failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`);
251
+ throw new Error(`Install script failed: ${describeSpawnFailure(result)}`);
246
252
  }
247
253
  }
248
254
  async #upgradeViaHomebrew() {
249
255
  const { tap, formula } = this.#config.homebrew;
250
256
  const result = await this.#spawnService.spawn(["brew", "upgrade", `${tap}/${formula}`]);
251
257
  if (!result.ok) {
252
- throw new Error(`brew upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`);
258
+ throw new Error(`brew upgrade failed: ${describeSpawnFailure(result)}`);
253
259
  }
254
260
  }
255
261
  async #upgradeViaWinget() {
@@ -264,7 +270,7 @@ export default class DefaultUpgradeService {
264
270
  "--accept-source-agreements",
265
271
  ]);
266
272
  if (!result.ok) {
267
- throw new Error(`winget upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`);
273
+ throw new Error(`winget upgrade failed: ${describeSpawnFailure(result)}`);
268
274
  }
269
275
  }
270
276
  async #upgradeViaGithubRelease(os, arch) {
@@ -272,8 +278,7 @@ export default class DefaultUpgradeService {
272
278
  const archLabel = os === SupportedOs.MACOS ? "aarch64" : arch === SupportedArch.X64 ? "x64" : "arm64";
273
279
  const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
274
280
  const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
275
- // Downloading the release asset happens outside SpawnService (which enters long-running mode
276
- // for its own subprocesses by default), so bracket it here to get the same cooperative
281
+ // Downloading the release asset but enter long-running mode to get cooperative
277
282
  // Ctrl-C handling during what can be the slowest step of the upgrade.
278
283
  this.#shutdownService?.enterLongRunningMode();
279
284
  let archiveData;
@@ -1 +1 @@
1
- {"version":3,"file":"UpgradeServiceProvider.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/UpgradeServiceProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAczE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAK1E,MAAM,CAAC,OAAO,OAAO,sBAAuB,YAAW,eAAe;;IACpE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAsB;IAChD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;gBAKd,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,sBAAsB;IAKnE,cAAc,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC;IASpD,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;CAgH1D"}
1
+ {"version":3,"file":"UpgradeServiceProvider.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/UpgradeServiceProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAczE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAK1E,MAAM,CAAC,OAAO,OAAO,sBAAuB,YAAW,eAAe;;IACpE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAsB;IAChD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;gBAKd,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,sBAAsB;IAKnE,cAAc,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC;IASpD,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;CAkH1D"}
@@ -39,6 +39,7 @@ export default class UpgradeServiceProvider {
39
39
  else {
40
40
  logger.debug(() => "SpawnService not available, upgrade install methods will be unavailable");
41
41
  }
42
+ void upgradeService.getUpgradeCheckResult();
42
43
  if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
43
44
  logger.debug(() => "PrompterService not available, skipping auto-upgrade");
44
45
  return;
@@ -101,7 +102,7 @@ export default class UpgradeServiceProvider {
101
102
  }
102
103
  async #checkAndUpgrade(context, upgradeService, cliConfig) {
103
104
  try {
104
- const checkResult = await upgradeService.checkForUpgrade();
105
+ const checkResult = await upgradeService.getUpgradeCheckResult();
105
106
  if (!checkResult?.updateAvailable) {
106
107
  return;
107
108
  }
@@ -1 +1 @@
1
- {"version":3,"file":"UpgradeSubCommand.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/command/UpgradeSubCommand.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yCAAyC,CAAC;AACtE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,EACL,KAAK,cAAc,EAEpB,MAAM,yCAAyC,CAAC;AASjD,OAAO,KAAK,qBAAqB,MAAM,6BAA6B,CAAC;AAErE,qBAAa,iBAAkB,YAAW,UAAU;;IAClD,QAAQ,CAAC,IAAI,aAAa;IAC1B,QAAQ,CAAC,WAAW,6CAA6C;IACjE,QAAQ,CAAC,mBAAmB,SAAS;IACrC,QAAQ,CAAC,WAAW,UAAM;IAE1B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAerC;gBAIiB,cAAc,EAAE,qBAAqB;IAI3C,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAsCtF"}
1
+ {"version":3,"file":"UpgradeSubCommand.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/command/UpgradeSubCommand.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yCAAyC,CAAC;AAC1E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yCAAyC,CAAC;AACtE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yCAAyC,CAAC;AACvE,OAAO,EACL,KAAK,cAAc,EAEpB,MAAM,yCAAyC,CAAC;AASjD,OAAO,KAAK,qBAAqB,MAAM,6BAA6B,CAAC;AAErE,qBAAa,iBAAkB,YAAW,UAAU;;IAClD,QAAQ,CAAC,IAAI,aAAa;IAC1B,QAAQ,CAAC,WAAW,6CAA6C;IACjE,QAAQ,CAAC,mBAAmB,SAAS;IACrC,QAAQ,CAAC,WAAW,UAAM;IAE1B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAerC;gBAIiB,cAAc,EAAE,qBAAqB;IAI3C,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAyCtF"}
@@ -31,7 +31,9 @@ export class UpgradeSubCommand {
31
31
  const os = argumentValues.os;
32
32
  const arch = undefined;
33
33
  const installMethod = argumentValues["install-method"];
34
- const checkResult = await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
34
+ const checkResult = os === undefined && installMethod === undefined
35
+ ? await this.#upgradeService.getUpgradeCheckResult(true)
36
+ : await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
35
37
  if (!checkResult) {
36
38
  await printerService.error(`No upgrade location is configured for the detected or requested platform.\n`, Icon.FAILURE);
37
39
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowscripter/dynamic-cli-framework",
3
- "version": "2.3.3",
3
+ "version": "2.4.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": "^1.2.0",
51
+ "@flowscripter/dynamic-cli-framework-api": "^1.3.0",
52
52
  "@flowscripter/dynamic-plugin-framework": "1.11.1",
53
53
  "emphasize": "^7.0.0",
54
54
  "fastest-levenshtein": "^1.0.16",
@@ -19,7 +19,7 @@ export default class VersionCommand implements GlobalCommand {
19
19
  let line = context.cliConfig.version;
20
20
  if (context.doesServiceExist(UPGRADE_SERVICE_ID)) {
21
21
  const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as UpgradeService;
22
- const result = await upgradeService.checkForUpgrade().catch(() => undefined);
22
+ const result = await upgradeService.getUpgradeCheckResult().catch(() => undefined);
23
23
  if (result?.updateAvailable) {
24
24
  line += ` (${result.latestVersion} available, run '${context.cliConfig.name} upgrade')`;
25
25
  }
@@ -72,7 +72,7 @@ export default class BannerServiceProvider implements ServiceProvider {
72
72
  }
73
73
  if (context.doesServiceExist(UPGRADE_SERVICE_ID)) {
74
74
  const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as UpgradeService;
75
- const result = await upgradeService.checkForUpgrade().catch(() => undefined);
75
+ const result = await upgradeService.getUpgradeCheckResult().catch(() => undefined);
76
76
  if (result?.updateAvailable) {
77
77
  await printerService.info(
78
78
  ` ${printerService.secondary(
@@ -55,8 +55,11 @@ export default class SpawnInterfaceAdapter implements SpawnInterface {
55
55
  this.#printerService.endMark();
56
56
  await this.#printerService.clearMarked(this.#markMinimumDisplayTimeMs);
57
57
 
58
- return result.ok
59
- ? { ok: true, exitCode: result.exitCode }
58
+ if (result.ok) {
59
+ return { ok: true, exitCode: result.exitCode };
60
+ }
61
+ return "timedOut" in result
62
+ ? { ok: false, error: new Error("Command timed out") }
60
63
  : { ok: false, exitCode: result.exitCode, error: result.error };
61
64
  }
62
65
  }
@@ -41,6 +41,22 @@ export default class DefaultSpawnService implements SpawnService {
41
41
  this.#shutdownService = shutdownService;
42
42
  }
43
43
 
44
+ async #terminate(
45
+ proc: ReturnType<typeof Bun.spawn>,
46
+ command: ReadonlyArray<string>,
47
+ ): Promise<void> {
48
+ logger.debug(() => `Sending SIGTERM to spawned command '${command.join(" ")}'`);
49
+ proc.kill("SIGTERM");
50
+ const exitedInTime = await Promise.race([
51
+ proc.exited.then(() => true),
52
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), SHUTDOWN_GRACE_PERIOD_MS)),
53
+ ]);
54
+ if (!exitedInTime) {
55
+ logger.debug(() => `Sending SIGKILL to spawned command '${command.join(" ")}'`);
56
+ proc.kill("SIGKILL");
57
+ }
58
+ }
59
+
44
60
  async #pipeLines(
45
61
  stream: ReadableStream<Uint8Array> | number | undefined,
46
62
  streamName: "stdout" | "stderr",
@@ -110,16 +126,7 @@ export default class DefaultSpawnService implements SpawnService {
110
126
  if (settled) {
111
127
  return;
112
128
  }
113
- logger.debug(() => `Sending SIGTERM to spawned command '${command.join(" ")}'`);
114
- proc.kill("SIGTERM");
115
- await Promise.race([
116
- proc.exited.then(() => {}),
117
- new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_PERIOD_MS)),
118
- ]);
119
- if (!settled) {
120
- logger.debug(() => `Sending SIGKILL to spawned command '${command.join(" ")}'`);
121
- proc.kill("SIGKILL");
122
- }
129
+ await this.#terminate(proc, command);
123
130
  });
124
131
 
125
132
  if (stdio === "wrapped" && options.onOutput) {
@@ -129,6 +136,28 @@ export default class DefaultSpawnService implements SpawnService {
129
136
  }
130
137
 
131
138
  try {
139
+ if (options.timeoutMs === undefined) {
140
+ const exitCode = await proc.exited;
141
+ settled = true;
142
+ return exitCode === 0 ? { ok: true, exitCode } : { ok: false, exitCode };
143
+ }
144
+
145
+ let timeoutHandle: ReturnType<typeof setTimeout>;
146
+ const timedOut = await Promise.race([
147
+ proc.exited.then(() => false),
148
+ new Promise<boolean>((resolve) => {
149
+ timeoutHandle = setTimeout(() => resolve(true), options.timeoutMs);
150
+ }),
151
+ ]);
152
+ clearTimeout(timeoutHandle!);
153
+
154
+ if (timedOut) {
155
+ logger.debug(() => `Command '${command.join(" ")}' timed out after ${options.timeoutMs}ms`);
156
+ await this.#terminate(proc, command);
157
+ settled = true;
158
+ return { ok: false, timedOut: true };
159
+ }
160
+
132
161
  const exitCode = await proc.exited;
133
162
  settled = true;
134
163
  return exitCode === 0 ? { ok: true, exitCode } : { ok: false, exitCode };
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import type {
6
6
  CLIConfig,
7
7
  ShutdownService,
8
+ SpawnResult,
8
9
  SpawnService,
9
10
  UpgradeCheckResult,
10
11
  UpgradeResult,
@@ -22,18 +23,13 @@ import getLogger from "../../util/logger.ts";
22
23
  const logger = getLogger("DefaultUpgradeService");
23
24
 
24
25
  // checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
25
- // so its version-check network calls must never be allowed to stall CLI startup.
26
- const VERSION_CHECK_TIMEOUT_MS = 250;
27
-
28
- // SpawnService has no built-in cancellation, so a stalled `brew`/`winget` invocation (e.g. winget's
29
- // first-ever run on a machine prompting to accept Microsoft Store terms) can otherwise hang
30
- // checkForUpgrade() indefinitely. Racing against this bounds how long we wait for a result; the
31
- // underlying process is not killed, but the CLI stops blocking on it.
32
- async function withSpawnTimeout<T>(spawnPromise: Promise<T>, fallback: T): Promise<T> {
33
- return Promise.race([
34
- spawnPromise,
35
- new Promise<T>((resolve) => setTimeout(() => resolve(fallback), VERSION_CHECK_TIMEOUT_MS)),
36
- ]);
26
+ // so its version-check network/spawn calls must never be allowed to stall CLI startup.
27
+ export const VERSION_CHECK_TIMEOUT_MS = 250;
28
+
29
+ function describeSpawnFailure(result: Extract<SpawnResult, { ok: false }>): string {
30
+ return "timedOut" in result
31
+ ? "timed out"
32
+ : (result.error?.message ?? `exit code ${result.exitCode}`);
37
33
  }
38
34
 
39
35
  const OS_LABELS: Record<SupportedOs, string> = {
@@ -45,6 +41,7 @@ const OS_LABELS: Record<SupportedOs, string> = {
45
41
  export default class DefaultUpgradeService implements UpgradeService {
46
42
  #spawnService: SpawnService | undefined;
47
43
  #shutdownService: ShutdownService | undefined;
44
+ #upgradeCheckPromise: Promise<UpgradeCheckResult | undefined> | undefined;
48
45
  readonly #config: UpgradeLocationsConfig;
49
46
  readonly #cliConfig: CLIConfig;
50
47
 
@@ -58,6 +55,21 @@ export default class DefaultUpgradeService implements UpgradeService {
58
55
  this.#shutdownService = shutdownService;
59
56
  }
60
57
 
58
+ public getUpgradeCheckResult(waitForResult = false): Promise<UpgradeCheckResult | undefined> {
59
+ if (!this.#upgradeCheckPromise) {
60
+ this.#upgradeCheckPromise = this.checkForUpgrade();
61
+ }
62
+ if (waitForResult) {
63
+ return this.#upgradeCheckPromise;
64
+ }
65
+ return Promise.race([
66
+ this.#upgradeCheckPromise,
67
+ new Promise<undefined>((resolve) =>
68
+ setTimeout(() => resolve(undefined), VERSION_CHECK_TIMEOUT_MS),
69
+ ),
70
+ ]);
71
+ }
72
+
61
73
  public detectOs(): SupportedOs | undefined {
62
74
  switch (process.platform) {
63
75
  case "linux":
@@ -143,7 +155,11 @@ export default class DefaultUpgradeService implements UpgradeService {
143
155
  installMethodOverride?: InstallMethod,
144
156
  ): Promise<UpgradeResult> {
145
157
  const oldVersion = this.#cliConfig.version;
146
- const checkResult = await this.checkForUpgrade(osOverride, archOverride, installMethodOverride);
158
+ const hasOverride =
159
+ osOverride !== undefined || archOverride !== undefined || installMethodOverride !== undefined;
160
+ const checkResult = hasOverride
161
+ ? await this.checkForUpgrade(osOverride, archOverride, installMethodOverride)
162
+ : await this.getUpgradeCheckResult(true);
147
163
  if (!checkResult) {
148
164
  return {
149
165
  ok: false,
@@ -186,12 +202,9 @@ export default class DefaultUpgradeService implements UpgradeService {
186
202
  if (!this.#spawnService || !this.#config.homebrew) {
187
203
  return false;
188
204
  }
189
- const result = await withSpawnTimeout(
190
- this.#spawnService.spawn(["brew", "list", "--versions", this.#config.homebrew.formula], {
191
- stdio: "wrapped",
192
- longRunning: false,
193
- }),
194
- { ok: false } as const,
205
+ const result = await this.#spawnService.spawn(
206
+ ["brew", "list", "--versions", this.#config.homebrew.formula],
207
+ { stdio: "wrapped", longRunning: false, timeoutMs: VERSION_CHECK_TIMEOUT_MS },
195
208
  );
196
209
  return result.ok;
197
210
  }
@@ -200,12 +213,9 @@ export default class DefaultUpgradeService implements UpgradeService {
200
213
  if (!this.#spawnService || !this.#config.winget) {
201
214
  return false;
202
215
  }
203
- const result = await withSpawnTimeout(
204
- this.#spawnService.spawn(["winget", "list", "--id", this.#config.winget.packageId], {
205
- stdio: "wrapped",
206
- longRunning: false,
207
- }),
208
- { ok: false } as const,
216
+ const result = await this.#spawnService.spawn(
217
+ ["winget", "list", "--id", this.#config.winget.packageId],
218
+ { stdio: "wrapped", longRunning: false, timeoutMs: VERSION_CHECK_TIMEOUT_MS },
209
219
  );
210
220
  return result.ok;
211
221
  }
@@ -279,13 +289,14 @@ export default class DefaultUpgradeService implements UpgradeService {
279
289
  return undefined;
280
290
  }
281
291
  const lines: string[] = [];
282
- const result = await withSpawnTimeout(
283
- this.#spawnService.spawn(["winget", "show", "--id", this.#config.winget.packageId], {
292
+ const result = await this.#spawnService.spawn(
293
+ ["winget", "show", "--id", this.#config.winget.packageId],
294
+ {
284
295
  stdio: "wrapped",
285
296
  longRunning: false,
297
+ timeoutMs: VERSION_CHECK_TIMEOUT_MS,
286
298
  onOutput: (line) => lines.push(line),
287
- }),
288
- { ok: false } as const,
299
+ },
289
300
  );
290
301
  if (!result.ok) {
291
302
  return undefined;
@@ -303,9 +314,7 @@ export default class DefaultUpgradeService implements UpgradeService {
303
314
  const { scriptUrl } = this.#config.linuxScript!;
304
315
  const result = await this.#spawnService!.spawn(["sh", "-c", `curl -fsSL ${scriptUrl} | sh`]);
305
316
  if (!result.ok) {
306
- throw new Error(
307
- `Install script failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
308
- );
317
+ throw new Error(`Install script failed: ${describeSpawnFailure(result)}`);
309
318
  }
310
319
  }
311
320
 
@@ -313,9 +322,7 @@ export default class DefaultUpgradeService implements UpgradeService {
313
322
  const { tap, formula } = this.#config.homebrew!;
314
323
  const result = await this.#spawnService!.spawn(["brew", "upgrade", `${tap}/${formula}`]);
315
324
  if (!result.ok) {
316
- throw new Error(
317
- `brew upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
318
- );
325
+ throw new Error(`brew upgrade failed: ${describeSpawnFailure(result)}`);
319
326
  }
320
327
  }
321
328
 
@@ -331,9 +338,7 @@ export default class DefaultUpgradeService implements UpgradeService {
331
338
  "--accept-source-agreements",
332
339
  ]);
333
340
  if (!result.ok) {
334
- throw new Error(
335
- `winget upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
336
- );
341
+ throw new Error(`winget upgrade failed: ${describeSpawnFailure(result)}`);
337
342
  }
338
343
  }
339
344
 
@@ -344,8 +349,7 @@ export default class DefaultUpgradeService implements UpgradeService {
344
349
  const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
345
350
  const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
346
351
 
347
- // Downloading the release asset happens outside SpawnService (which enters long-running mode
348
- // for its own subprocesses by default), so bracket it here to get the same cooperative
352
+ // Downloading the release asset but enter long-running mode to get cooperative
349
353
  // Ctrl-C handling during what can be the slowest step of the upgrade.
350
354
  this.#shutdownService?.enterLongRunningMode();
351
355
  let archiveData: ArrayBuffer;
@@ -54,6 +54,8 @@ export default class UpgradeServiceProvider implements ServiceProvider {
54
54
  logger.debug(() => "SpawnService not available, upgrade install methods will be unavailable");
55
55
  }
56
56
 
57
+ void upgradeService.getUpgradeCheckResult();
58
+
57
59
  if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
58
60
  logger.debug(() => "PrompterService not available, skipping auto-upgrade");
59
61
  return;
@@ -128,7 +130,7 @@ export default class UpgradeServiceProvider implements ServiceProvider {
128
130
  cliConfig: CLIConfig,
129
131
  ): Promise<void> {
130
132
  try {
131
- const checkResult = await upgradeService.checkForUpgrade();
133
+ const checkResult = await upgradeService.getUpgradeCheckResult();
132
134
  if (!checkResult?.updateAvailable) {
133
135
  return;
134
136
  }
@@ -52,7 +52,10 @@ export class UpgradeSubCommand implements SubCommand {
52
52
  const arch = undefined as SupportedArch | undefined;
53
53
  const installMethod = argumentValues["install-method"] as InstallMethod | undefined;
54
54
 
55
- const checkResult = await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
55
+ const checkResult =
56
+ os === undefined && installMethod === undefined
57
+ ? await this.#upgradeService.getUpgradeCheckResult(true)
58
+ : await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
56
59
  if (!checkResult) {
57
60
  await printerService.error(
58
61
  `No upgrade location is configured for the detected or requested platform.\n`,