@flowscripter/dynamic-cli-framework 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,8 +12,8 @@ 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.getUpgradeCheckResult().catch(() => undefined);
16
- if (result?.updateAvailable) {
15
+ const result = await upgradeService.getUpgradeCheckResult();
16
+ if (result.status === "checked" && result.updateAvailable) {
17
17
  line += ` (${result.latestVersion} available, run '${context.cliConfig.name} upgrade')`;
18
18
  }
19
19
  }
@@ -52,8 +52,8 @@ 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.getUpgradeCheckResult().catch(() => undefined);
56
- if (result?.updateAvailable) {
55
+ const result = await upgradeService.getUpgradeCheckResult();
56
+ if (result.status === "checked" && result.updateAvailable) {
57
57
  await printerService.info(` ${printerService.secondary(`(${result.latestVersion} available, run '${cliConfig.name} upgrade')`)}\n`);
58
58
  }
59
59
  }
@@ -6,11 +6,11 @@ export default class DefaultUpgradeService implements UpgradeService {
6
6
  #private;
7
7
  constructor(config: UpgradeLocationsConfig, cliConfig: CLIConfig);
8
8
  setDependencies(spawnService: SpawnService | undefined, fetchService: FetchService | undefined): void;
9
- getUpgradeCheckResult(waitForResult?: boolean): Promise<UpgradeCheckResult | undefined>;
9
+ getUpgradeCheckResult(waitForResult?: boolean): Promise<UpgradeCheckResult>;
10
10
  detectOs(): SupportedOs | undefined;
11
11
  detectArch(): SupportedArch | undefined;
12
12
  detectInstallMethod(os: SupportedOs): Promise<InstallMethod | undefined>;
13
- checkForUpgrade(osOverride?: SupportedOs, archOverride?: SupportedArch, installMethodOverride?: InstallMethod): Promise<UpgradeCheckResult | undefined>;
13
+ checkForUpgrade(osOverride?: SupportedOs, archOverride?: SupportedArch, installMethodOverride?: InstallMethod): Promise<UpgradeCheckResult>;
14
14
  upgrade(osOverride?: SupportedOs, archOverride?: SupportedArch, installMethodOverride?: InstallMethod): Promise<UpgradeResult>;
15
15
  }
16
16
  //# sourceMappingURL=DefaultUpgradeService.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEZ,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;AAS1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAc5C,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAO/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CACpB,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,YAAY,EAAE,YAAY,GAAG,SAAS,GACrC,IAAI;IAKA,qBAAqB,CAAC,aAAa,UAAQ,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IA0BrF,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;CAoQ1B"}
1
+ {"version":3,"file":"DefaultUpgradeService.d.ts","sourceRoot":"","sources":["../../../../src/service/upgrade/DefaultUpgradeService.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEZ,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;AAM1E,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAkB5C,MAAM,CAAC,OAAO,OAAO,qBAAsB,YAAW,cAAc;;gBAO/C,MAAM,EAAE,sBAAsB,EAAE,SAAS,EAAE,SAAS;IAKhE,eAAe,CACpB,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,YAAY,EAAE,YAAY,GAAG,SAAS,GACrC,IAAI;IAKA,qBAAqB,CAAC,aAAa,UAAQ,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0BzE,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,CAAC;IAwCjB,OAAO,CAClB,UAAU,CAAC,EAAE,WAAW,EACxB,YAAY,CAAC,EAAE,aAAa,EAC5B,qBAAqB,CAAC,EAAE,aAAa,GACpC,OAAO,CAAC,aAAa,CAAC;CA4S1B"}
@@ -4,8 +4,6 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { InstallMethod, SupportedArch, SupportedOs, } from "@flowscripter/dynamic-cli-framework-api";
6
6
  import semver from "semver";
7
- import getLogger from "../../util/logger.js";
8
- const logger = getLogger("DefaultUpgradeService");
9
7
  // checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
10
8
  // so that opportunistic call is raced against this timeout so it never stalls CLI startup.
11
9
  // A deliberate caller (waitForResult=true, e.g. the `upgrade` command) awaits checkForUpgrade()
@@ -37,14 +35,14 @@ export default class DefaultUpgradeService {
37
35
  }
38
36
  getUpgradeCheckResult(waitForResult = false) {
39
37
  if (!this.#upgradeCheckPromise) {
40
- // Only cache a successful result. An `undefined` result can come from a transient
41
- // failure (e.g. the opportunistic startup check racing past VERSION_CHECK_TIMEOUT_MS
42
- // before checkForUpgrade() itself resolves) - caching that would permanently deny a
43
- // later, deliberate caller (e.g. the `upgrade` command explicitly waiting via
44
- // waitForResult=true) any chance of a fresh attempt for the rest of this process's
45
- // lifetime.
38
+ // Only cache a non-"failed" result. A "failed" result can come from a transient issue
39
+ // (e.g. a network blip, or the opportunistic startup check racing past
40
+ // VERSION_CHECK_TIMEOUT_MS before checkForUpgrade() itself resolves) - caching that would
41
+ // permanently deny a later, deliberate caller (e.g. the `upgrade` command explicitly
42
+ // waiting via waitForResult=true) any chance of a fresh attempt for the rest of this
43
+ // process's lifetime.
46
44
  this.#upgradeCheckPromise = this.checkForUpgrade().then((result) => {
47
- if (result === undefined) {
45
+ if (result.status === "failed") {
48
46
  this.#upgradeCheckPromise = undefined;
49
47
  }
50
48
  return result;
@@ -55,7 +53,7 @@ export default class DefaultUpgradeService {
55
53
  }
56
54
  return Promise.race([
57
55
  this.#upgradeCheckPromise,
58
- new Promise((resolve) => setTimeout(() => resolve(undefined), VERSION_CHECK_TIMEOUT_MS)),
56
+ new Promise((resolve) => setTimeout(() => resolve({ status: "pending" }), VERSION_CHECK_TIMEOUT_MS)),
59
57
  ]);
60
58
  }
61
59
  detectOs() {
@@ -99,26 +97,29 @@ export default class DefaultUpgradeService {
99
97
  const os = osOverride ?? this.detectOs();
100
98
  const arch = archOverride ?? this.detectArch();
101
99
  if (!os || !arch || !this.#isPlatformSupported(os, arch)) {
102
- return undefined;
100
+ return { status: "unsupported" };
103
101
  }
104
102
  const installMethod = installMethodOverride ?? (await this.detectInstallMethod(os));
105
103
  if (!installMethod) {
106
- return undefined;
104
+ return { status: "unsupported" };
107
105
  }
108
- const latestVersion = await this.#getLatestVersion(installMethod);
109
- if (!latestVersion) {
110
- return undefined;
106
+ const versionLookup = await this.#getLatestVersion(installMethod);
107
+ if (!versionLookup.ok) {
108
+ return { status: "failed", error: versionLookup.error };
111
109
  }
112
110
  const currentVersion = this.#cliConfig.version;
113
111
  const coercedCurrent = semver.coerce(currentVersion);
114
- const coercedLatest = semver.coerce(latestVersion);
112
+ const coercedLatest = semver.coerce(versionLookup.version);
115
113
  if (!coercedCurrent || !coercedLatest) {
116
- logger.debug(() => `Unable to compare versions '${currentVersion}' and '${latestVersion}'`);
117
- return undefined;
114
+ return {
115
+ status: "failed",
116
+ error: new Error(`Unable to compare versions '${currentVersion}' and '${versionLookup.version}'`),
117
+ };
118
118
  }
119
119
  return {
120
+ status: "checked",
120
121
  currentVersion,
121
- latestVersion,
122
+ latestVersion: versionLookup.version,
122
123
  updateAvailable: semver.gt(coercedLatest, coercedCurrent),
123
124
  os,
124
125
  arch,
@@ -131,13 +132,20 @@ export default class DefaultUpgradeService {
131
132
  const checkResult = hasOverride
132
133
  ? await this.checkForUpgrade(osOverride, archOverride, installMethodOverride)
133
134
  : await this.getUpgradeCheckResult(true);
134
- if (!checkResult) {
135
+ if (checkResult.status === "unsupported") {
135
136
  return {
136
137
  ok: false,
137
138
  oldVersion,
138
139
  error: new Error("No upgrade location configured for the detected or requested platform"),
139
140
  };
140
141
  }
142
+ if (checkResult.status === "failed") {
143
+ return { ok: false, oldVersion, error: checkResult.error };
144
+ }
145
+ if (checkResult.status === "pending") {
146
+ // Unreachable: checkForUpgrade() and getUpgradeCheckResult(true) always run to completion.
147
+ return { ok: false, oldVersion, error: new Error("Upgrade check did not complete") };
148
+ }
141
149
  if (!this.#spawnService) {
142
150
  return { ok: false, oldVersion, error: new Error("SpawnService is not available") };
143
151
  }
@@ -194,53 +202,81 @@ export default class DefaultUpgradeService {
194
202
  return this.#getLatestHomebrewVersion();
195
203
  case InstallMethod.WINGET:
196
204
  return this.#getLatestWingetVersion();
197
- default:
198
- return undefined;
199
205
  }
200
206
  }
201
207
  async #getLatestGithubReleaseVersion() {
202
- if (!this.#config.githubRelease || !this.#fetchService) {
203
- return undefined;
208
+ if (!this.#config.githubRelease) {
209
+ return { ok: false, error: new Error("No githubRelease location configured") };
210
+ }
211
+ if (!this.#fetchService) {
212
+ return { ok: false, error: new Error("FetchService is not available") };
204
213
  }
205
214
  const { owner, repo } = this.#config.githubRelease;
206
215
  try {
207
- const response = await this.#fetchService.fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
208
- if (!response.ok) {
209
- return undefined;
216
+ // Uses the plain web redirect rather than the api.github.com REST endpoint, since the
217
+ // latter's unauthenticated rate limit (60 requests/hour/IP) is easily exhausted, e.g. by
218
+ // CI runners sharing an IP pool.
219
+ const response = await this.#fetchService.fetch(`https://github.com/${owner}/${repo}/releases/latest`, { redirect: "manual" });
220
+ const location = response.headers.get("location");
221
+ const version = location ? /\/releases\/tag\/v?([^/]+)$/.exec(location)?.[1] : undefined;
222
+ if (!version) {
223
+ return {
224
+ ok: false,
225
+ error: new Error(`Unexpected response resolving latest release for ${owner}/${repo}: HTTP ${response.status}`),
226
+ };
210
227
  }
211
- const data = (await response.json());
212
- return data.tag_name?.replace(/^v/, "");
228
+ return { ok: true, version };
213
229
  }
214
230
  catch (error) {
215
- logger.debug(() => `Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`);
216
- return undefined;
231
+ return {
232
+ ok: false,
233
+ error: new Error(`Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`),
234
+ };
217
235
  }
218
236
  }
219
237
  async #getLatestHomebrewVersion() {
220
- if (!this.#config.homebrew || !this.#fetchService) {
221
- return undefined;
238
+ if (!this.#config.homebrew) {
239
+ return { ok: false, error: new Error("No homebrew location configured") };
240
+ }
241
+ if (!this.#fetchService) {
242
+ return { ok: false, error: new Error("FetchService is not available") };
222
243
  }
223
244
  const { tap, formula } = this.#config.homebrew;
224
245
  const [tapOwner, tapName] = tap.split("/");
225
246
  if (!tapOwner || !tapName) {
226
- return undefined;
247
+ return { ok: false, error: new Error(`Invalid homebrew tap '${tap}'`) };
227
248
  }
228
249
  try {
229
250
  const response = await this.#fetchService.fetch(`https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`);
230
251
  if (!response.ok) {
231
- return undefined;
252
+ return {
253
+ ok: false,
254
+ error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: HTTP ${response.status}`),
255
+ };
232
256
  }
233
257
  const text = await response.text();
234
- return /version\s+"v?([^"]+)"/.exec(text)?.[1];
258
+ const version = /version\s+"v?([^"]+)"/.exec(text)?.[1];
259
+ if (!version) {
260
+ return {
261
+ ok: false,
262
+ error: new Error(`Could not parse version from homebrew formula ${tap}/${formula}`),
263
+ };
264
+ }
265
+ return { ok: true, version };
235
266
  }
236
267
  catch (error) {
237
- logger.debug(() => `Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`);
238
- return undefined;
268
+ return {
269
+ ok: false,
270
+ error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`),
271
+ };
239
272
  }
240
273
  }
241
274
  async #getLatestWingetVersion() {
242
- if (!this.#spawnService || !this.#config.winget) {
243
- return undefined;
275
+ if (!this.#config.winget) {
276
+ return { ok: false, error: new Error("No winget location configured") };
277
+ }
278
+ if (!this.#spawnService) {
279
+ return { ok: false, error: new Error("SpawnService is not available") };
244
280
  }
245
281
  const lines = [];
246
282
  const result = await this.#spawnService.spawn(["winget", "show", "--id", this.#config.winget.packageId], {
@@ -249,15 +285,15 @@ export default class DefaultUpgradeService {
249
285
  onOutput: (line) => lines.push(line),
250
286
  });
251
287
  if (!result.ok) {
252
- return undefined;
288
+ return { ok: false, error: new Error(`winget show failed: ${describeSpawnFailure(result)}`) };
253
289
  }
254
290
  for (const line of lines) {
255
291
  const match = /Version:\s*(\S+)/.exec(line);
256
292
  if (match?.[1]) {
257
- return match[1];
293
+ return { ok: true, version: match[1] };
258
294
  }
259
295
  }
260
- return undefined;
296
+ return { ok: false, error: new Error("Could not parse version from winget output") };
261
297
  }
262
298
  async #upgradeViaLinuxScript() {
263
299
  const { scriptUrl } = this.#config.linuxScript;
@@ -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;CAsH1D"}
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;CA0H1D"}
@@ -106,7 +106,11 @@ export default class UpgradeServiceProvider {
106
106
  async #checkAndUpgrade(context, upgradeService, cliConfig) {
107
107
  try {
108
108
  const checkResult = await upgradeService.getUpgradeCheckResult();
109
- if (!checkResult?.updateAvailable) {
109
+ if (checkResult.status === "failed") {
110
+ logger.debug(() => `Auto-upgrade check failed: ${checkResult.error.message}`);
111
+ return;
112
+ }
113
+ if (checkResult.status !== "checked" || !checkResult.updateAvailable) {
110
114
  return;
111
115
  }
112
116
  const upgradeResult = await upgradeService.upgrade();
@@ -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,EAAE,KAAK,MAAM,EAAiB,MAAM,yCAAyC,CAAC;AASrF,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,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAyC9E"}
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,EAAE,KAAK,MAAM,EAAiB,MAAM,yCAAyC,CAAC;AASrF,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,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAuD9E"}
@@ -34,10 +34,19 @@ export class UpgradeSubCommand {
34
34
  const checkResult = os === undefined && installMethod === undefined
35
35
  ? await this.#upgradeService.getUpgradeCheckResult(true)
36
36
  : await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
37
- if (!checkResult) {
37
+ if (checkResult.status === "unsupported") {
38
38
  await printerService.error(`No upgrade location is configured for the detected or requested platform.\n`, Icon.FAILURE);
39
39
  return;
40
40
  }
41
+ if (checkResult.status === "failed") {
42
+ await printerService.error(`Failed to check for updates: ${checkResult.error.message}. Please try again later.\n`, Icon.FAILURE);
43
+ return;
44
+ }
45
+ if (checkResult.status === "pending") {
46
+ // Unreachable: getUpgradeCheckResult(true) and checkForUpgrade() always run to completion.
47
+ await printerService.error(`Failed to check for updates.\n`, Icon.FAILURE);
48
+ return;
49
+ }
41
50
  if (!checkResult.updateAvailable) {
42
51
  await printerService.print(`${cliName} is already up to date (${checkResult.currentVersion}).\n`);
43
52
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowscripter/dynamic-cli-framework",
3
- "version": "4.0.0",
3
+ "version": "5.0.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.5.0",
51
+ "@flowscripter/dynamic-cli-framework-api": "^2.0.0",
52
52
  "@flowscripter/dynamic-plugin-framework": "2.0.0",
53
53
  "emphasize": "^7.0.0",
54
54
  "fastest-levenshtein": "^1.0.16",
@@ -19,8 +19,8 @@ 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.getUpgradeCheckResult().catch(() => undefined);
23
- if (result?.updateAvailable) {
22
+ const result = await upgradeService.getUpgradeCheckResult();
23
+ if (result.status === "checked" && result.updateAvailable) {
24
24
  line += ` (${result.latestVersion} available, run '${context.cliConfig.name} upgrade')`;
25
25
  }
26
26
  }
@@ -72,8 +72,8 @@ 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.getUpgradeCheckResult().catch(() => undefined);
76
- if (result?.updateAvailable) {
75
+ const result = await upgradeService.getUpgradeCheckResult();
76
+ if (result.status === "checked" && result.updateAvailable) {
77
77
  await printerService.info(
78
78
  ` ${printerService.secondary(
79
79
  `(${result.latestVersion} available, run '${cliConfig.name} upgrade')`,
@@ -18,9 +18,6 @@ import {
18
18
  } from "@flowscripter/dynamic-cli-framework-api";
19
19
  import semver from "semver";
20
20
  import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
21
- import getLogger from "../../util/logger.ts";
22
-
23
- const logger = getLogger("DefaultUpgradeService");
24
21
 
25
22
  // checkForUpgrade() runs opportunistically on every CLI invocation (via BannerServiceProvider),
26
23
  // so that opportunistic call is raced against this timeout so it never stalls CLI startup.
@@ -28,6 +25,10 @@ const logger = getLogger("DefaultUpgradeService");
28
25
  // directly with no timeout, so its underlying network/spawn calls are never artificially cut off.
29
26
  export const VERSION_CHECK_TIMEOUT_MS = 250;
30
27
 
28
+ type VersionLookupResult =
29
+ | { readonly ok: true; readonly version: string }
30
+ | { readonly ok: false; readonly error: Error };
31
+
31
32
  function describeSpawnFailure(result: Extract<SpawnResult, { ok: false }>): string {
32
33
  return "timedOut" in result
33
34
  ? "timed out"
@@ -43,7 +44,7 @@ const OS_LABELS: Record<SupportedOs, string> = {
43
44
  export default class DefaultUpgradeService implements UpgradeService {
44
45
  #spawnService: SpawnService | undefined;
45
46
  #fetchService: FetchService | undefined;
46
- #upgradeCheckPromise: Promise<UpgradeCheckResult | undefined> | undefined;
47
+ #upgradeCheckPromise: Promise<UpgradeCheckResult> | undefined;
47
48
  readonly #config: UpgradeLocationsConfig;
48
49
  readonly #cliConfig: CLIConfig;
49
50
 
@@ -60,16 +61,16 @@ export default class DefaultUpgradeService implements UpgradeService {
60
61
  this.#fetchService = fetchService;
61
62
  }
62
63
 
63
- public getUpgradeCheckResult(waitForResult = false): Promise<UpgradeCheckResult | undefined> {
64
+ public getUpgradeCheckResult(waitForResult = false): Promise<UpgradeCheckResult> {
64
65
  if (!this.#upgradeCheckPromise) {
65
- // Only cache a successful result. An `undefined` result can come from a transient
66
- // failure (e.g. the opportunistic startup check racing past VERSION_CHECK_TIMEOUT_MS
67
- // before checkForUpgrade() itself resolves) - caching that would permanently deny a
68
- // later, deliberate caller (e.g. the `upgrade` command explicitly waiting via
69
- // waitForResult=true) any chance of a fresh attempt for the rest of this process's
70
- // lifetime.
66
+ // Only cache a non-"failed" result. A "failed" result can come from a transient issue
67
+ // (e.g. a network blip, or the opportunistic startup check racing past
68
+ // VERSION_CHECK_TIMEOUT_MS before checkForUpgrade() itself resolves) - caching that would
69
+ // permanently deny a later, deliberate caller (e.g. the `upgrade` command explicitly
70
+ // waiting via waitForResult=true) any chance of a fresh attempt for the rest of this
71
+ // process's lifetime.
71
72
  this.#upgradeCheckPromise = this.checkForUpgrade().then((result) => {
72
- if (result === undefined) {
73
+ if (result.status === "failed") {
73
74
  this.#upgradeCheckPromise = undefined;
74
75
  }
75
76
  return result;
@@ -80,8 +81,8 @@ export default class DefaultUpgradeService implements UpgradeService {
80
81
  }
81
82
  return Promise.race([
82
83
  this.#upgradeCheckPromise,
83
- new Promise<undefined>((resolve) =>
84
- setTimeout(() => resolve(undefined), VERSION_CHECK_TIMEOUT_MS),
84
+ new Promise<UpgradeCheckResult>((resolve) =>
85
+ setTimeout(() => resolve({ status: "pending" }), VERSION_CHECK_TIMEOUT_MS),
85
86
  ),
86
87
  ]);
87
88
  }
@@ -130,34 +131,39 @@ export default class DefaultUpgradeService implements UpgradeService {
130
131
  osOverride?: SupportedOs,
131
132
  archOverride?: SupportedArch,
132
133
  installMethodOverride?: InstallMethod,
133
- ): Promise<UpgradeCheckResult | undefined> {
134
+ ): Promise<UpgradeCheckResult> {
134
135
  const os = osOverride ?? this.detectOs();
135
136
  const arch = archOverride ?? this.detectArch();
136
137
  if (!os || !arch || !this.#isPlatformSupported(os, arch)) {
137
- return undefined;
138
+ return { status: "unsupported" };
138
139
  }
139
140
 
140
141
  const installMethod = installMethodOverride ?? (await this.detectInstallMethod(os));
141
142
  if (!installMethod) {
142
- return undefined;
143
+ return { status: "unsupported" };
143
144
  }
144
145
 
145
- const latestVersion = await this.#getLatestVersion(installMethod);
146
- if (!latestVersion) {
147
- return undefined;
146
+ const versionLookup = await this.#getLatestVersion(installMethod);
147
+ if (!versionLookup.ok) {
148
+ return { status: "failed", error: versionLookup.error };
148
149
  }
149
150
 
150
151
  const currentVersion = this.#cliConfig.version;
151
152
  const coercedCurrent = semver.coerce(currentVersion);
152
- const coercedLatest = semver.coerce(latestVersion);
153
+ const coercedLatest = semver.coerce(versionLookup.version);
153
154
  if (!coercedCurrent || !coercedLatest) {
154
- logger.debug(() => `Unable to compare versions '${currentVersion}' and '${latestVersion}'`);
155
- return undefined;
155
+ return {
156
+ status: "failed",
157
+ error: new Error(
158
+ `Unable to compare versions '${currentVersion}' and '${versionLookup.version}'`,
159
+ ),
160
+ };
156
161
  }
157
162
 
158
163
  return {
164
+ status: "checked",
159
165
  currentVersion,
160
- latestVersion,
166
+ latestVersion: versionLookup.version,
161
167
  updateAvailable: semver.gt(coercedLatest, coercedCurrent),
162
168
  os,
163
169
  arch,
@@ -176,13 +182,20 @@ export default class DefaultUpgradeService implements UpgradeService {
176
182
  const checkResult = hasOverride
177
183
  ? await this.checkForUpgrade(osOverride, archOverride, installMethodOverride)
178
184
  : await this.getUpgradeCheckResult(true);
179
- if (!checkResult) {
185
+ if (checkResult.status === "unsupported") {
180
186
  return {
181
187
  ok: false,
182
188
  oldVersion,
183
189
  error: new Error("No upgrade location configured for the detected or requested platform"),
184
190
  };
185
191
  }
192
+ if (checkResult.status === "failed") {
193
+ return { ok: false, oldVersion, error: checkResult.error };
194
+ }
195
+ if (checkResult.status === "pending") {
196
+ // Unreachable: checkForUpgrade() and getUpgradeCheckResult(true) always run to completion.
197
+ return { ok: false, oldVersion, error: new Error("Upgrade check did not complete") };
198
+ }
186
199
  if (!this.#spawnService) {
187
200
  return { ok: false, oldVersion, error: new Error("SpawnService is not available") };
188
201
  }
@@ -243,7 +256,7 @@ export default class DefaultUpgradeService implements UpgradeService {
243
256
  return process.execPath.startsWith("/usr/local/bin/");
244
257
  }
245
258
 
246
- async #getLatestVersion(installMethod: InstallMethod): Promise<string | undefined> {
259
+ async #getLatestVersion(installMethod: InstallMethod): Promise<VersionLookupResult> {
247
260
  switch (installMethod) {
248
261
  case InstallMethod.GITHUB_RELEASE:
249
262
  case InstallMethod.LINUX_SCRIPT:
@@ -252,58 +265,91 @@ export default class DefaultUpgradeService implements UpgradeService {
252
265
  return this.#getLatestHomebrewVersion();
253
266
  case InstallMethod.WINGET:
254
267
  return this.#getLatestWingetVersion();
255
- default:
256
- return undefined;
257
268
  }
258
269
  }
259
270
 
260
- async #getLatestGithubReleaseVersion(): Promise<string | undefined> {
261
- if (!this.#config.githubRelease || !this.#fetchService) {
262
- return undefined;
271
+ async #getLatestGithubReleaseVersion(): Promise<VersionLookupResult> {
272
+ if (!this.#config.githubRelease) {
273
+ return { ok: false, error: new Error("No githubRelease location configured") };
274
+ }
275
+ if (!this.#fetchService) {
276
+ return { ok: false, error: new Error("FetchService is not available") };
263
277
  }
264
278
  const { owner, repo } = this.#config.githubRelease;
265
279
  try {
280
+ // Uses the plain web redirect rather than the api.github.com REST endpoint, since the
281
+ // latter's unauthenticated rate limit (60 requests/hour/IP) is easily exhausted, e.g. by
282
+ // CI runners sharing an IP pool.
266
283
  const response = await this.#fetchService.fetch(
267
- `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
284
+ `https://github.com/${owner}/${repo}/releases/latest`,
285
+ { redirect: "manual" },
268
286
  );
269
- if (!response.ok) {
270
- return undefined;
287
+ const location = response.headers.get("location");
288
+ const version = location ? /\/releases\/tag\/v?([^/]+)$/.exec(location)?.[1] : undefined;
289
+ if (!version) {
290
+ return {
291
+ ok: false,
292
+ error: new Error(
293
+ `Unexpected response resolving latest release for ${owner}/${repo}: HTTP ${response.status}`,
294
+ ),
295
+ };
271
296
  }
272
- const data = (await response.json()) as { tag_name?: string };
273
- return data.tag_name?.replace(/^v/, "");
297
+ return { ok: true, version };
274
298
  } catch (error) {
275
- logger.debug(() => `Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`);
276
- return undefined;
299
+ return {
300
+ ok: false,
301
+ error: new Error(`Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`),
302
+ };
277
303
  }
278
304
  }
279
305
 
280
- async #getLatestHomebrewVersion(): Promise<string | undefined> {
281
- if (!this.#config.homebrew || !this.#fetchService) {
282
- return undefined;
306
+ async #getLatestHomebrewVersion(): Promise<VersionLookupResult> {
307
+ if (!this.#config.homebrew) {
308
+ return { ok: false, error: new Error("No homebrew location configured") };
309
+ }
310
+ if (!this.#fetchService) {
311
+ return { ok: false, error: new Error("FetchService is not available") };
283
312
  }
284
313
  const { tap, formula } = this.#config.homebrew;
285
314
  const [tapOwner, tapName] = tap.split("/");
286
315
  if (!tapOwner || !tapName) {
287
- return undefined;
316
+ return { ok: false, error: new Error(`Invalid homebrew tap '${tap}'`) };
288
317
  }
289
318
  try {
290
319
  const response = await this.#fetchService.fetch(
291
320
  `https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`,
292
321
  );
293
322
  if (!response.ok) {
294
- return undefined;
323
+ return {
324
+ ok: false,
325
+ error: new Error(
326
+ `Failed to fetch homebrew formula for ${tap}/${formula}: HTTP ${response.status}`,
327
+ ),
328
+ };
295
329
  }
296
330
  const text = await response.text();
297
- return /version\s+"v?([^"]+)"/.exec(text)?.[1];
331
+ const version = /version\s+"v?([^"]+)"/.exec(text)?.[1];
332
+ if (!version) {
333
+ return {
334
+ ok: false,
335
+ error: new Error(`Could not parse version from homebrew formula ${tap}/${formula}`),
336
+ };
337
+ }
338
+ return { ok: true, version };
298
339
  } catch (error) {
299
- logger.debug(() => `Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`);
300
- return undefined;
340
+ return {
341
+ ok: false,
342
+ error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`),
343
+ };
301
344
  }
302
345
  }
303
346
 
304
- async #getLatestWingetVersion(): Promise<string | undefined> {
305
- if (!this.#spawnService || !this.#config.winget) {
306
- return undefined;
347
+ async #getLatestWingetVersion(): Promise<VersionLookupResult> {
348
+ if (!this.#config.winget) {
349
+ return { ok: false, error: new Error("No winget location configured") };
350
+ }
351
+ if (!this.#spawnService) {
352
+ return { ok: false, error: new Error("SpawnService is not available") };
307
353
  }
308
354
  const lines: string[] = [];
309
355
  const result = await this.#spawnService.spawn(
@@ -315,15 +361,15 @@ export default class DefaultUpgradeService implements UpgradeService {
315
361
  },
316
362
  );
317
363
  if (!result.ok) {
318
- return undefined;
364
+ return { ok: false, error: new Error(`winget show failed: ${describeSpawnFailure(result)}`) };
319
365
  }
320
366
  for (const line of lines) {
321
367
  const match = /Version:\s*(\S+)/.exec(line);
322
368
  if (match?.[1]) {
323
- return match[1];
369
+ return { ok: true, version: match[1] };
324
370
  }
325
371
  }
326
- return undefined;
372
+ return { ok: false, error: new Error("Could not parse version from winget output") };
327
373
  }
328
374
 
329
375
  async #upgradeViaLinuxScript(): Promise<void> {
@@ -135,7 +135,11 @@ export default class UpgradeServiceProvider implements ServiceProvider {
135
135
  ): Promise<void> {
136
136
  try {
137
137
  const checkResult = await upgradeService.getUpgradeCheckResult();
138
- if (!checkResult?.updateAvailable) {
138
+ if (checkResult.status === "failed") {
139
+ logger.debug(() => `Auto-upgrade check failed: ${checkResult.error.message}`);
140
+ return;
141
+ }
142
+ if (checkResult.status !== "checked" || !checkResult.updateAvailable) {
139
143
  return;
140
144
  }
141
145
  const upgradeResult = await upgradeService.upgrade();
@@ -53,7 +53,7 @@ export class UpgradeSubCommand implements SubCommand {
53
53
  os === undefined && installMethod === undefined
54
54
  ? await this.#upgradeService.getUpgradeCheckResult(true)
55
55
  : await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
56
- if (!checkResult) {
56
+ if (checkResult.status === "unsupported") {
57
57
  await printerService.error(
58
58
  `No upgrade location is configured for the detected or requested platform.\n`,
59
59
  Icon.FAILURE,
@@ -61,6 +61,20 @@ export class UpgradeSubCommand implements SubCommand {
61
61
  return;
62
62
  }
63
63
 
64
+ if (checkResult.status === "failed") {
65
+ await printerService.error(
66
+ `Failed to check for updates: ${checkResult.error.message}. Please try again later.\n`,
67
+ Icon.FAILURE,
68
+ );
69
+ return;
70
+ }
71
+
72
+ if (checkResult.status === "pending") {
73
+ // Unreachable: getUpgradeCheckResult(true) and checkForUpgrade() always run to completion.
74
+ await printerService.error(`Failed to check for updates.\n`, Icon.FAILURE);
75
+ return;
76
+ }
77
+
64
78
  if (!checkResult.updateAvailable) {
65
79
  await printerService.print(
66
80
  `${cliName} is already up to date (${checkResult.currentVersion}).\n`,