@flowscripter/dynamic-cli-framework 9.0.3 → 9.0.4
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.
- package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
- package/dist/src/service/upgrade/DefaultUpgradeService.js +18 -330
- package/dist/src/service/upgrade/method/githubRelease.d.ts +6 -0
- package/dist/src/service/upgrade/method/githubRelease.d.ts.map +1 -0
- package/dist/src/service/upgrade/method/githubRelease.js +132 -0
- package/dist/src/service/upgrade/method/homebrew.d.ts +11 -0
- package/dist/src/service/upgrade/method/homebrew.d.ts.map +1 -0
- package/dist/src/service/upgrade/method/homebrew.js +132 -0
- package/dist/src/service/upgrade/method/linuxScript.d.ts +5 -0
- package/dist/src/service/upgrade/method/linuxScript.d.ts.map +1 -0
- package/dist/src/service/upgrade/method/linuxScript.js +15 -0
- package/dist/src/service/upgrade/method/shared.d.ts +13 -0
- package/dist/src/service/upgrade/method/shared.d.ts.map +1 -0
- package/dist/src/service/upgrade/method/shared.js +33 -0
- package/dist/src/service/upgrade/method/winget.d.ts +7 -0
- package/dist/src/service/upgrade/method/winget.d.ts.map +1 -0
- package/dist/src/service/upgrade/method/winget.js +49 -0
- package/package.json +1 -1
- package/src/service/upgrade/DefaultUpgradeService.ts +38 -377
- package/src/service/upgrade/method/githubRelease.ts +163 -0
- package/src/service/upgrade/method/homebrew.ts +165 -0
- package/src/service/upgrade/method/linuxScript.ts +23 -0
- package/src/service/upgrade/method/shared.ts +51 -0
- package/src/service/upgrade/method/winget.ts +64 -0
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdtemp, rename, rm } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { basename, dirname, join } from "node:path";
|
|
6
2
|
import type {
|
|
7
3
|
CLIConfig,
|
|
8
4
|
Context,
|
|
@@ -29,6 +25,17 @@ import {
|
|
|
29
25
|
} from "@flowscripter/dynamic-cli-framework-api";
|
|
30
26
|
import semver from "semver";
|
|
31
27
|
import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
|
|
28
|
+
import { getLatestGithubReleaseVersion, upgradeViaGithubRelease } from "./method/githubRelease.ts";
|
|
29
|
+
import {
|
|
30
|
+
getLatestHomebrewVersion,
|
|
31
|
+
isHomebrewInstalled,
|
|
32
|
+
isRunningFromHomebrewCellar,
|
|
33
|
+
resolveHomebrewOptExecutable,
|
|
34
|
+
upgradeViaHomebrew,
|
|
35
|
+
} from "./method/homebrew.ts";
|
|
36
|
+
import { isLinuxScriptInstall, upgradeViaLinuxScript } from "./method/linuxScript.ts";
|
|
37
|
+
import type { VersionLookupResult } from "./method/shared.ts";
|
|
38
|
+
import { getLatestWingetVersion, isWingetInstalled, upgradeViaWinget } from "./method/winget.ts";
|
|
32
39
|
import getLogger from "../../util/logger.ts";
|
|
33
40
|
|
|
34
41
|
const logger = getLogger("DefaultUpgradeService");
|
|
@@ -70,16 +77,6 @@ interface CachedLatestVersion {
|
|
|
70
77
|
// since checking live would stall startup on the network/spawn calls checkForUpgrade() makes.
|
|
71
78
|
export const UPGRADE_CHECK_CACHE_KEY = "upgrade-check-result";
|
|
72
79
|
|
|
73
|
-
type VersionLookupResult =
|
|
74
|
-
| { readonly ok: true; readonly version: string }
|
|
75
|
-
| { readonly ok: false; readonly error: Error };
|
|
76
|
-
|
|
77
|
-
function describeSpawnFailure(result: Extract<SpawnResult, { ok: false }>): string {
|
|
78
|
-
return "timedOut" in result
|
|
79
|
-
? "timed out"
|
|
80
|
-
: (result.error?.message ?? `exit code ${result.exitCode}`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
80
|
export function describeUpgradeCheckResult(result: UpgradeCheckResult): string {
|
|
84
81
|
return result.status === "failed"
|
|
85
82
|
? `Upgrade check result: failed - ${result.error.message}`
|
|
@@ -126,12 +123,6 @@ export function toRunState(result: SpawnResult): RunState | undefined {
|
|
|
126
123
|
return RunState.RUNTIME_ERROR;
|
|
127
124
|
}
|
|
128
125
|
|
|
129
|
-
const OS_LABELS: Record<SupportedOs, string> = {
|
|
130
|
-
[SupportedOs.LINUX]: "Linux",
|
|
131
|
-
[SupportedOs.MACOS]: "MacOS",
|
|
132
|
-
[SupportedOs.WINDOWS]: "Windows",
|
|
133
|
-
};
|
|
134
|
-
|
|
135
126
|
export default class DefaultUpgradeService implements UpgradeService {
|
|
136
127
|
#context: Context | undefined;
|
|
137
128
|
#upgradeCheckPromise: Promise<UpgradeCheckResult> | undefined;
|
|
@@ -173,40 +164,6 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
173
164
|
: undefined;
|
|
174
165
|
}
|
|
175
166
|
|
|
176
|
-
// Mirrors SpawnInterfaceAdapter's plugin:add/plugin:remove pattern: wrap a spawned command's
|
|
177
|
-
// output in a quoted, marked block that's cleared on success (so a clean install stays quiet)
|
|
178
|
-
// but left on screen on failure (so the diagnostic output remains visible).
|
|
179
|
-
async #spawnQuoted(command: ReadonlyArray<string>): Promise<SpawnResult> {
|
|
180
|
-
const spawnService = this.#spawnService!;
|
|
181
|
-
const printerService = this.#printerService;
|
|
182
|
-
if (!printerService) {
|
|
183
|
-
return spawnService.spawn(command, { mode: "ignore" });
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
printerService.startQuote();
|
|
187
|
-
printerService.startMark();
|
|
188
|
-
|
|
189
|
-
// onOutput is synchronous and may be called concurrently for stdout/stderr lines, but
|
|
190
|
-
// printerService.info() is async and must not be invoked concurrently with itself - queue
|
|
191
|
-
// writes so they're applied one at a time, in call order.
|
|
192
|
-
let writeQueue: Promise<void> = Promise.resolve();
|
|
193
|
-
const onOutput = (line: string): void => {
|
|
194
|
-
writeQueue = writeQueue.then(() => printerService.info(`${line}\n`));
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const result = await spawnService.spawn(command, { mode: "wrapped", onOutput });
|
|
198
|
-
await writeQueue;
|
|
199
|
-
|
|
200
|
-
printerService.endQuote();
|
|
201
|
-
printerService.endMark();
|
|
202
|
-
if (result.ok) {
|
|
203
|
-
await printerService.clearMarked();
|
|
204
|
-
} else {
|
|
205
|
-
printerService.discardMark();
|
|
206
|
-
}
|
|
207
|
-
return result;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
167
|
public getUpgradeCheckResult(): Promise<UpgradeCheckResult> {
|
|
211
168
|
if (!this.#upgradeCheckPromise) {
|
|
212
169
|
logger.debug(() => "Starting upgrade check");
|
|
@@ -269,30 +226,12 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
269
226
|
case InstallMethod.LINUX_SCRIPT:
|
|
270
227
|
return process.execPath;
|
|
271
228
|
case InstallMethod.HOMEBREW:
|
|
272
|
-
return this.#
|
|
229
|
+
return resolveHomebrewOptExecutable(this.#config.homebrew);
|
|
273
230
|
case InstallMethod.WINGET:
|
|
274
231
|
return undefined;
|
|
275
232
|
}
|
|
276
233
|
}
|
|
277
234
|
|
|
278
|
-
// The running executable lives in a version-specific Cellar directory, e.g.
|
|
279
|
-
// "<prefix>/Cellar/<formula>/<version>/bin/<exe>", while "<prefix>/opt/<formula>" links to the
|
|
280
|
-
// installed version, so the upgraded executable is "<prefix>/opt/<formula>/bin/<exe>".
|
|
281
|
-
#resolveHomebrewOptExecutable(): string | undefined {
|
|
282
|
-
if (!this.#config.homebrew) {
|
|
283
|
-
return undefined;
|
|
284
|
-
}
|
|
285
|
-
const { formula } = this.#config.homebrew;
|
|
286
|
-
const realExecutable = this.#resolveRealExecutable();
|
|
287
|
-
const cellarIndex = realExecutable.indexOf(`/Cellar/${formula}/`);
|
|
288
|
-
if (cellarIndex === -1) {
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
291
|
-
const prefix = realExecutable.slice(0, cellarIndex);
|
|
292
|
-
const executable = join(prefix, "opt", formula, "bin", basename(realExecutable));
|
|
293
|
-
return existsSync(executable) ? executable : undefined;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
235
|
/**
|
|
297
236
|
* Run `executable` with `args` in place of this process, with terminal input and output
|
|
298
237
|
* inherited, and wait for it to exit. The child process sees the current version as its
|
|
@@ -350,7 +289,7 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
350
289
|
process.platform === "darwin" &&
|
|
351
290
|
os === SupportedOs.MACOS &&
|
|
352
291
|
this.#config.homebrew &&
|
|
353
|
-
|
|
292
|
+
isRunningFromHomebrewCellar(this.#config.homebrew.formula)
|
|
354
293
|
) {
|
|
355
294
|
return InstallMethod.HOMEBREW;
|
|
356
295
|
}
|
|
@@ -358,7 +297,7 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
358
297
|
process.platform === "linux" &&
|
|
359
298
|
os === SupportedOs.LINUX &&
|
|
360
299
|
this.#config.linuxScript &&
|
|
361
|
-
|
|
300
|
+
isLinuxScriptInstall()
|
|
362
301
|
) {
|
|
363
302
|
return InstallMethod.LINUX_SCRIPT;
|
|
364
303
|
}
|
|
@@ -375,14 +314,14 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
375
314
|
process.platform === "darwin" &&
|
|
376
315
|
os === SupportedOs.MACOS &&
|
|
377
316
|
this.#config.homebrew &&
|
|
378
|
-
(await this.#
|
|
317
|
+
(await isHomebrewInstalled(this.#spawnService, this.#config.homebrew))
|
|
379
318
|
) {
|
|
380
319
|
detected = InstallMethod.HOMEBREW;
|
|
381
320
|
} else if (
|
|
382
321
|
process.platform === "win32" &&
|
|
383
322
|
os === SupportedOs.WINDOWS &&
|
|
384
323
|
this.#config.winget &&
|
|
385
|
-
(await this.#
|
|
324
|
+
(await isWingetInstalled(this.#spawnService, this.#config.winget))
|
|
386
325
|
) {
|
|
387
326
|
detected = InstallMethod.WINGET;
|
|
388
327
|
} else if (this.#config.githubRelease) {
|
|
@@ -507,18 +446,33 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
507
446
|
if (this.#printerService) {
|
|
508
447
|
await this.#printerService.hideSpinner();
|
|
509
448
|
}
|
|
449
|
+
const spawnService = this.#spawnService!;
|
|
450
|
+
const printerService = this.#printerService;
|
|
510
451
|
switch (checkResult.installMethod) {
|
|
511
452
|
case InstallMethod.LINUX_SCRIPT:
|
|
512
|
-
await this.#
|
|
453
|
+
await upgradeViaLinuxScript(spawnService, printerService, this.#config.linuxScript!);
|
|
513
454
|
break;
|
|
514
455
|
case InstallMethod.HOMEBREW:
|
|
515
|
-
await
|
|
456
|
+
await upgradeViaHomebrew(
|
|
457
|
+
spawnService,
|
|
458
|
+
printerService,
|
|
459
|
+
this.#config.homebrew!,
|
|
460
|
+
checkResult.latestVersion,
|
|
461
|
+
);
|
|
516
462
|
break;
|
|
517
463
|
case InstallMethod.WINGET:
|
|
518
|
-
await this.#
|
|
464
|
+
await upgradeViaWinget(spawnService, printerService, this.#config.winget!);
|
|
519
465
|
break;
|
|
520
466
|
case InstallMethod.GITHUB_RELEASE:
|
|
521
|
-
await
|
|
467
|
+
await upgradeViaGithubRelease(
|
|
468
|
+
spawnService,
|
|
469
|
+
this.#fetchService!,
|
|
470
|
+
printerService,
|
|
471
|
+
this.#config.githubRelease!,
|
|
472
|
+
this.#cliConfig.name,
|
|
473
|
+
checkResult.os,
|
|
474
|
+
checkResult.arch,
|
|
475
|
+
);
|
|
522
476
|
break;
|
|
523
477
|
}
|
|
524
478
|
return { ok: true, oldVersion, newVersion: checkResult.latestVersion };
|
|
@@ -533,50 +487,6 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
533
487
|
);
|
|
534
488
|
}
|
|
535
489
|
|
|
536
|
-
// Homebrew relinks a formula's installed binary from its Cellar directory into a `bin/` symlink,
|
|
537
|
-
// so resolving the running executable's real path confirms a homebrew install without spawning
|
|
538
|
-
// `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
|
|
539
|
-
// StartupTask (see UpgradeServiceProvider) fast even though it runs to completion.
|
|
540
|
-
#isRunningFromHomebrewCellar(formula: string): boolean {
|
|
541
|
-
return this.#resolveRealExecutable().includes(`/Cellar/${formula}/`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
#resolveRealExecutable(): string {
|
|
545
|
-
try {
|
|
546
|
-
return realpathSync(process.execPath);
|
|
547
|
-
} catch {
|
|
548
|
-
// process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
|
|
549
|
-
// the unresolved path rather than treating that as "not installed".
|
|
550
|
-
return process.execPath;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
async #isHomebrewInstalled(): Promise<boolean> {
|
|
555
|
-
if (!this.#spawnService || !this.#config.homebrew) {
|
|
556
|
-
return false;
|
|
557
|
-
}
|
|
558
|
-
const result = await this.#spawnService.spawn(
|
|
559
|
-
["brew", "list", "--versions", this.#config.homebrew.formula],
|
|
560
|
-
{ mode: "ignore", longRunning: false },
|
|
561
|
-
);
|
|
562
|
-
return result.ok;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async #isWingetInstalled(): Promise<boolean> {
|
|
566
|
-
if (!this.#spawnService || !this.#config.winget) {
|
|
567
|
-
return false;
|
|
568
|
-
}
|
|
569
|
-
const result = await this.#spawnService.spawn(
|
|
570
|
-
["winget", "list", "--id", this.#config.winget.packageId],
|
|
571
|
-
{ mode: "ignore", longRunning: false },
|
|
572
|
-
);
|
|
573
|
-
return result.ok;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
#isLinuxScriptInstall(): boolean {
|
|
577
|
-
return process.execPath.startsWith("/usr/local/bin/");
|
|
578
|
-
}
|
|
579
|
-
|
|
580
490
|
async #getLatestVersion(installMethod: InstallMethod): Promise<VersionLookupResult> {
|
|
581
491
|
const cacheKey = `${LATEST_VERSION_CACHE_KEY_PREFIX}${installMethod}`;
|
|
582
492
|
const keyValueService = this.#keyValueService;
|
|
@@ -605,260 +515,11 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
605
515
|
switch (installMethod) {
|
|
606
516
|
case InstallMethod.GITHUB_RELEASE:
|
|
607
517
|
case InstallMethod.LINUX_SCRIPT:
|
|
608
|
-
return this.#
|
|
518
|
+
return getLatestGithubReleaseVersion(this.#fetchService, this.#config.githubRelease);
|
|
609
519
|
case InstallMethod.HOMEBREW:
|
|
610
|
-
return this.#
|
|
520
|
+
return getLatestHomebrewVersion(this.#fetchService, this.#config.homebrew);
|
|
611
521
|
case InstallMethod.WINGET:
|
|
612
|
-
return this.#
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async #getLatestGithubReleaseVersion(): Promise<VersionLookupResult> {
|
|
617
|
-
if (!this.#config.githubRelease) {
|
|
618
|
-
return { ok: false, error: new Error("No githubRelease location configured") };
|
|
619
|
-
}
|
|
620
|
-
if (!this.#fetchService) {
|
|
621
|
-
return { ok: false, error: new Error("FetchService is not available") };
|
|
622
|
-
}
|
|
623
|
-
const { owner, repo } = this.#config.githubRelease;
|
|
624
|
-
try {
|
|
625
|
-
// Uses the plain web redirect rather than the api.github.com REST endpoint, since the
|
|
626
|
-
// latter's unauthenticated rate limit (60 requests/hour/IP) is easily exhausted, e.g. by
|
|
627
|
-
// CI runners sharing an IP pool.
|
|
628
|
-
const response = await this.#fetchService.fetch(
|
|
629
|
-
`https://github.com/${owner}/${repo}/releases/latest`,
|
|
630
|
-
{ redirect: "manual" },
|
|
631
|
-
);
|
|
632
|
-
const location = response.headers.get("location");
|
|
633
|
-
const version = location ? /\/releases\/tag\/v?([^/]+)$/.exec(location)?.[1] : undefined;
|
|
634
|
-
if (!version) {
|
|
635
|
-
return {
|
|
636
|
-
ok: false,
|
|
637
|
-
error: new Error(
|
|
638
|
-
`Unexpected response resolving latest release for ${owner}/${repo}: HTTP ${response.status}`,
|
|
639
|
-
),
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
return { ok: true, version };
|
|
643
|
-
} catch (error) {
|
|
644
|
-
return {
|
|
645
|
-
ok: false,
|
|
646
|
-
error: new Error(`Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`),
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async #getLatestHomebrewVersion(): Promise<VersionLookupResult> {
|
|
652
|
-
if (!this.#config.homebrew) {
|
|
653
|
-
return { ok: false, error: new Error("No homebrew location configured") };
|
|
654
|
-
}
|
|
655
|
-
if (!this.#fetchService) {
|
|
656
|
-
return { ok: false, error: new Error("FetchService is not available") };
|
|
657
|
-
}
|
|
658
|
-
const { tap, formula } = this.#config.homebrew;
|
|
659
|
-
const [tapOwner, tapName] = tap.split("/");
|
|
660
|
-
if (!tapOwner || !tapName) {
|
|
661
|
-
return { ok: false, error: new Error(`Invalid homebrew tap '${tap}'`) };
|
|
662
|
-
}
|
|
663
|
-
try {
|
|
664
|
-
const response = await this.#fetchService.fetch(
|
|
665
|
-
`https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`,
|
|
666
|
-
);
|
|
667
|
-
if (!response.ok) {
|
|
668
|
-
return {
|
|
669
|
-
ok: false,
|
|
670
|
-
error: new Error(
|
|
671
|
-
`Failed to fetch homebrew formula for ${tap}/${formula}: HTTP ${response.status}`,
|
|
672
|
-
),
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
const text = await response.text();
|
|
676
|
-
const version = /version\s+"v?([^"]+)"/.exec(text)?.[1];
|
|
677
|
-
if (!version) {
|
|
678
|
-
return {
|
|
679
|
-
ok: false,
|
|
680
|
-
error: new Error(`Could not parse version from homebrew formula ${tap}/${formula}`),
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
return { ok: true, version };
|
|
684
|
-
} catch (error) {
|
|
685
|
-
return {
|
|
686
|
-
ok: false,
|
|
687
|
-
error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`),
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async #getLatestWingetVersion(): Promise<VersionLookupResult> {
|
|
693
|
-
if (!this.#config.winget) {
|
|
694
|
-
return { ok: false, error: new Error("No winget location configured") };
|
|
695
|
-
}
|
|
696
|
-
if (!this.#spawnService) {
|
|
697
|
-
return { ok: false, error: new Error("SpawnService is not available") };
|
|
698
|
-
}
|
|
699
|
-
const lines: string[] = [];
|
|
700
|
-
const result = await this.#spawnService.spawn(
|
|
701
|
-
["winget", "show", "--id", this.#config.winget.packageId],
|
|
702
|
-
{
|
|
703
|
-
mode: "wrapped",
|
|
704
|
-
longRunning: false,
|
|
705
|
-
onOutput: (line) => lines.push(line),
|
|
706
|
-
},
|
|
707
|
-
);
|
|
708
|
-
if (!result.ok) {
|
|
709
|
-
return { ok: false, error: new Error(`winget show failed: ${describeSpawnFailure(result)}`) };
|
|
710
|
-
}
|
|
711
|
-
for (const line of lines) {
|
|
712
|
-
const match = /Version:\s*(\S+)/.exec(line);
|
|
713
|
-
if (match?.[1]) {
|
|
714
|
-
return { ok: true, version: match[1] };
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
return { ok: false, error: new Error("Could not parse version from winget output") };
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
async #upgradeViaLinuxScript(): Promise<void> {
|
|
721
|
-
const { scriptUrl } = this.#config.linuxScript!;
|
|
722
|
-
const result = await this.#spawnQuoted(["sh", "-c", `curl -fsSL ${scriptUrl} | sh`]);
|
|
723
|
-
if (!result.ok) {
|
|
724
|
-
throw new Error(`Install script failed: ${describeSpawnFailure(result)}`);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// The latest version is read from the tap's formula on GitHub, but `brew upgrade` only sees the
|
|
729
|
-
// local tap clone, which is stale until `brew update` runs. A stale tap makes `brew upgrade`
|
|
730
|
-
// exit 0 with "already installed", so update first and then confirm the installed version.
|
|
731
|
-
async #upgradeViaHomebrew(expectedVersion: string): Promise<void> {
|
|
732
|
-
const { tap, formula } = this.#config.homebrew!;
|
|
733
|
-
const updateResult = await this.#spawnQuoted(["brew", "update"]);
|
|
734
|
-
if (!updateResult.ok) {
|
|
735
|
-
throw new Error(`brew update failed: ${describeSpawnFailure(updateResult)}`);
|
|
736
|
-
}
|
|
737
|
-
const result = await this.#spawnQuoted(["brew", "upgrade", `${tap}/${formula}`]);
|
|
738
|
-
if (!result.ok) {
|
|
739
|
-
throw new Error(`brew upgrade failed: ${describeSpawnFailure(result)}`);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const lines: string[] = [];
|
|
743
|
-
const listResult = await this.#spawnService!.spawn(["brew", "list", "--versions", formula], {
|
|
744
|
-
mode: "wrapped",
|
|
745
|
-
longRunning: false,
|
|
746
|
-
onOutput: (line) => lines.push(line),
|
|
747
|
-
});
|
|
748
|
-
const installed = lines.join(" ").trim();
|
|
749
|
-
const installedVersions = installed.split(/\s+/).slice(1);
|
|
750
|
-
if (!listResult.ok || !installedVersions.includes(expectedVersion)) {
|
|
751
|
-
throw new Error(
|
|
752
|
-
`brew upgrade completed but version ${expectedVersion} is not installed (installed: ${installed || "unknown"})`,
|
|
753
|
-
);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
async #upgradeViaWinget(): Promise<void> {
|
|
758
|
-
const { packageId } = this.#config.winget!;
|
|
759
|
-
const result = await this.#spawnQuoted([
|
|
760
|
-
"winget",
|
|
761
|
-
"upgrade",
|
|
762
|
-
"--id",
|
|
763
|
-
packageId,
|
|
764
|
-
"--silent",
|
|
765
|
-
"--accept-package-agreements",
|
|
766
|
-
"--accept-source-agreements",
|
|
767
|
-
]);
|
|
768
|
-
if (!result.ok) {
|
|
769
|
-
throw new Error(`winget upgrade failed: ${describeSpawnFailure(result)}`);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
async #upgradeViaGithubRelease(os: SupportedOs, arch: SupportedArch): Promise<void> {
|
|
774
|
-
const { owner, repo, assetPattern } = this.#config.githubRelease!;
|
|
775
|
-
// macOS release assets use "aarch64" rather than "arm64" for the arm64 build; x64 (including
|
|
776
|
-
// Intel Macs) always uses "x64" regardless of os.
|
|
777
|
-
const archLabel =
|
|
778
|
-
arch === SupportedArch.X64 ? "x64" : os === SupportedOs.MACOS ? "aarch64" : "arm64";
|
|
779
|
-
const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
|
|
780
|
-
const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
|
|
781
|
-
|
|
782
|
-
// longRunning: true gets cooperative Ctrl-C handling during what can be the slowest step of
|
|
783
|
-
// the upgrade.
|
|
784
|
-
const response = await this.#fetchService!.fetch(url, { longRunning: true });
|
|
785
|
-
if (!response.ok) {
|
|
786
|
-
throw new Error(`Failed to download release asset '${assetName}': HTTP ${response.status}`);
|
|
787
|
-
}
|
|
788
|
-
const archiveData = await response.arrayBuffer();
|
|
789
|
-
|
|
790
|
-
const tmpDir = await mkdtemp(join(tmpdir(), "upgrade-"));
|
|
791
|
-
const archivePath = join(tmpDir, assetName);
|
|
792
|
-
await Bun.write(archivePath, archiveData);
|
|
793
|
-
|
|
794
|
-
const currentExecutable = process.execPath;
|
|
795
|
-
|
|
796
|
-
if (os === SupportedOs.WINDOWS) {
|
|
797
|
-
const extractResult = await this.#spawnQuoted([
|
|
798
|
-
"powershell",
|
|
799
|
-
"-Command",
|
|
800
|
-
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
|
|
801
|
-
]);
|
|
802
|
-
if (!extractResult.ok) {
|
|
803
|
-
throw new Error("Failed to extract release archive");
|
|
804
|
-
}
|
|
805
|
-
const extractedBinary = join(tmpDir, `${this.#cliConfig.name}.exe`);
|
|
806
|
-
const oldPath = `${currentExecutable}.old.exe`;
|
|
807
|
-
|
|
808
|
-
// Best-effort cleanup of a stale "<exe>.old.exe" left behind by a *previous* upgrade run.
|
|
809
|
-
// Windows won't let us delete the just-renamed-aside exe while this process still has it
|
|
810
|
-
// open/mapped - that can only happen once we're no longer holding it, i.e. at the start of
|
|
811
|
-
// the NEXT invocation, before we move today's running exe aside. Ignore failures: the file
|
|
812
|
-
// may not exist, or may still be locked (e.g. another instance still running). Not quoted -
|
|
813
|
-
// this is expected to fail silently, there's nothing worth showing the user.
|
|
814
|
-
await this.#spawnService!.spawn(["cmd", "/c", "del", "/f", "/q", oldPath], {
|
|
815
|
-
mode: "ignore",
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
const moveResult = await this.#spawnQuoted([
|
|
819
|
-
"cmd",
|
|
820
|
-
"/c",
|
|
821
|
-
"move",
|
|
822
|
-
"/y",
|
|
823
|
-
currentExecutable,
|
|
824
|
-
oldPath,
|
|
825
|
-
]);
|
|
826
|
-
if (!moveResult.ok) {
|
|
827
|
-
throw new Error("Failed to move current executable aside");
|
|
828
|
-
}
|
|
829
|
-
const copyResult = await this.#spawnQuoted([
|
|
830
|
-
"cmd",
|
|
831
|
-
"/c",
|
|
832
|
-
"copy",
|
|
833
|
-
"/y",
|
|
834
|
-
extractedBinary,
|
|
835
|
-
currentExecutable,
|
|
836
|
-
]);
|
|
837
|
-
if (!copyResult.ok) {
|
|
838
|
-
throw new Error("Failed to copy new executable into place");
|
|
839
|
-
}
|
|
840
|
-
} else {
|
|
841
|
-
const extractResult = await this.#spawnQuoted(["unzip", "-o", archivePath, "-d", tmpDir]);
|
|
842
|
-
if (!extractResult.ok) {
|
|
843
|
-
throw new Error("Failed to extract release archive");
|
|
844
|
-
}
|
|
845
|
-
const extractedBinary = join(tmpDir, this.#cliConfig.name);
|
|
846
|
-
|
|
847
|
-
// Extract into a staging file in the SAME directory as the running executable (not
|
|
848
|
-
// os.tmpdir(), which may be a different filesystem/mount), then atomically rename it over
|
|
849
|
-
// currentExecutable. This avoids ETXTBSY: the kernel refuses to open-for-write the inode
|
|
850
|
-
// mapped as a running process's text segment, but rename() only swaps the directory entry
|
|
851
|
-
// to point at a different inode - the running process keeps executing from its original,
|
|
852
|
-
// now-unlinked-but-still-open inode until it next execs/restarts.
|
|
853
|
-
const stagingDir = await mkdtemp(join(dirname(currentExecutable), ".upgrade-"));
|
|
854
|
-
try {
|
|
855
|
-
const stagingBinary = join(stagingDir, this.#cliConfig.name);
|
|
856
|
-
await Bun.write(stagingBinary, Bun.file(extractedBinary));
|
|
857
|
-
await this.#spawnService!.spawn(["chmod", "+x", stagingBinary], { mode: "ignore" });
|
|
858
|
-
await rename(stagingBinary, currentExecutable);
|
|
859
|
-
} finally {
|
|
860
|
-
await rm(stagingDir, { recursive: true, force: true });
|
|
861
|
-
}
|
|
522
|
+
return getLatestWingetVersion(this.#spawnService, this.#config.winget);
|
|
862
523
|
}
|
|
863
524
|
}
|
|
864
525
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { mkdtemp, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type FetchService,
|
|
7
|
+
type PrinterService,
|
|
8
|
+
type SpawnService,
|
|
9
|
+
SupportedArch,
|
|
10
|
+
SupportedOs,
|
|
11
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
12
|
+
import type { GithubReleaseLocation } from "../UpgradeLocationsConfig.ts";
|
|
13
|
+
import { spawnQuoted, type VersionLookupResult } from "./shared.ts";
|
|
14
|
+
|
|
15
|
+
const OS_LABELS: Record<SupportedOs, string> = {
|
|
16
|
+
[SupportedOs.LINUX]: "Linux",
|
|
17
|
+
[SupportedOs.MACOS]: "MacOS",
|
|
18
|
+
[SupportedOs.WINDOWS]: "Windows",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function getLatestGithubReleaseVersion(
|
|
22
|
+
fetchService: FetchService | undefined,
|
|
23
|
+
location: GithubReleaseLocation | undefined,
|
|
24
|
+
): Promise<VersionLookupResult> {
|
|
25
|
+
if (!location) {
|
|
26
|
+
return { ok: false, error: new Error("No githubRelease location configured") };
|
|
27
|
+
}
|
|
28
|
+
if (!fetchService) {
|
|
29
|
+
return { ok: false, error: new Error("FetchService is not available") };
|
|
30
|
+
}
|
|
31
|
+
const { owner, repo } = location;
|
|
32
|
+
try {
|
|
33
|
+
// Uses the plain web redirect rather than the api.github.com REST endpoint, since the
|
|
34
|
+
// latter's unauthenticated rate limit (60 requests/hour/IP) is easily exhausted, e.g. by
|
|
35
|
+
// CI runners sharing an IP pool.
|
|
36
|
+
const response = await fetchService.fetch(
|
|
37
|
+
`https://github.com/${owner}/${repo}/releases/latest`,
|
|
38
|
+
{ redirect: "manual" },
|
|
39
|
+
);
|
|
40
|
+
const redirect = response.headers.get("location");
|
|
41
|
+
const version = redirect ? /\/releases\/tag\/v?([^/]+)$/.exec(redirect)?.[1] : undefined;
|
|
42
|
+
if (!version) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: new Error(
|
|
46
|
+
`Unexpected response resolving latest release for ${owner}/${repo}: HTTP ${response.status}`,
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, version };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: new Error(`Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function upgradeViaGithubRelease(
|
|
60
|
+
spawnService: SpawnService,
|
|
61
|
+
fetchService: FetchService,
|
|
62
|
+
printerService: PrinterService | undefined,
|
|
63
|
+
location: GithubReleaseLocation,
|
|
64
|
+
cliName: string,
|
|
65
|
+
os: SupportedOs,
|
|
66
|
+
arch: SupportedArch,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const { owner, repo, assetPattern } = location;
|
|
69
|
+
// macOS release assets use "aarch64" rather than "arm64" for the arm64 build; x64 (including
|
|
70
|
+
// Intel Macs) always uses "x64" regardless of os.
|
|
71
|
+
const archLabel =
|
|
72
|
+
arch === SupportedArch.X64 ? "x64" : os === SupportedOs.MACOS ? "aarch64" : "arm64";
|
|
73
|
+
const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
|
|
74
|
+
const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
|
|
75
|
+
|
|
76
|
+
// longRunning: true gets cooperative Ctrl-C handling during what can be the slowest step of
|
|
77
|
+
// the upgrade.
|
|
78
|
+
const response = await fetchService.fetch(url, { longRunning: true });
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`Failed to download release asset '${assetName}': HTTP ${response.status}`);
|
|
81
|
+
}
|
|
82
|
+
const archiveData = await response.arrayBuffer();
|
|
83
|
+
|
|
84
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "upgrade-"));
|
|
85
|
+
const archivePath = join(tmpDir, assetName);
|
|
86
|
+
await Bun.write(archivePath, archiveData);
|
|
87
|
+
|
|
88
|
+
const currentExecutable = process.execPath;
|
|
89
|
+
|
|
90
|
+
if (os === SupportedOs.WINDOWS) {
|
|
91
|
+
const extractResult = await spawnQuoted(spawnService, printerService, [
|
|
92
|
+
"powershell",
|
|
93
|
+
"-Command",
|
|
94
|
+
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
|
|
95
|
+
]);
|
|
96
|
+
if (!extractResult.ok) {
|
|
97
|
+
throw new Error("Failed to extract release archive");
|
|
98
|
+
}
|
|
99
|
+
const extractedBinary = join(tmpDir, `${cliName}.exe`);
|
|
100
|
+
const oldPath = `${currentExecutable}.old.exe`;
|
|
101
|
+
|
|
102
|
+
// Best-effort cleanup of a stale "<exe>.old.exe" left behind by a *previous* upgrade run.
|
|
103
|
+
// Windows won't let us delete the just-renamed-aside exe while this process still has it
|
|
104
|
+
// open/mapped - that can only happen once we're no longer holding it, i.e. at the start of
|
|
105
|
+
// the NEXT invocation, before we move today's running exe aside. Ignore failures: the file
|
|
106
|
+
// may not exist, or may still be locked (e.g. another instance still running). Not quoted -
|
|
107
|
+
// this is expected to fail silently, there's nothing worth showing the user.
|
|
108
|
+
await spawnService.spawn(["cmd", "/c", "del", "/f", "/q", oldPath], {
|
|
109
|
+
mode: "ignore",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const moveResult = await spawnQuoted(spawnService, printerService, [
|
|
113
|
+
"cmd",
|
|
114
|
+
"/c",
|
|
115
|
+
"move",
|
|
116
|
+
"/y",
|
|
117
|
+
currentExecutable,
|
|
118
|
+
oldPath,
|
|
119
|
+
]);
|
|
120
|
+
if (!moveResult.ok) {
|
|
121
|
+
throw new Error("Failed to move current executable aside");
|
|
122
|
+
}
|
|
123
|
+
const copyResult = await spawnQuoted(spawnService, printerService, [
|
|
124
|
+
"cmd",
|
|
125
|
+
"/c",
|
|
126
|
+
"copy",
|
|
127
|
+
"/y",
|
|
128
|
+
extractedBinary,
|
|
129
|
+
currentExecutable,
|
|
130
|
+
]);
|
|
131
|
+
if (!copyResult.ok) {
|
|
132
|
+
throw new Error("Failed to copy new executable into place");
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const extractResult = await spawnQuoted(spawnService, printerService, [
|
|
136
|
+
"unzip",
|
|
137
|
+
"-o",
|
|
138
|
+
archivePath,
|
|
139
|
+
"-d",
|
|
140
|
+
tmpDir,
|
|
141
|
+
]);
|
|
142
|
+
if (!extractResult.ok) {
|
|
143
|
+
throw new Error("Failed to extract release archive");
|
|
144
|
+
}
|
|
145
|
+
const extractedBinary = join(tmpDir, cliName);
|
|
146
|
+
|
|
147
|
+
// Extract into a staging file in the SAME directory as the running executable (not
|
|
148
|
+
// os.tmpdir(), which may be a different filesystem/mount), then atomically rename it over
|
|
149
|
+
// currentExecutable. This avoids ETXTBSY: the kernel refuses to open-for-write the inode
|
|
150
|
+
// mapped as a running process's text segment, but rename() only swaps the directory entry
|
|
151
|
+
// to point at a different inode - the running process keeps executing from its original,
|
|
152
|
+
// now-unlinked-but-still-open inode until it next execs/restarts.
|
|
153
|
+
const stagingDir = await mkdtemp(join(dirname(currentExecutable), ".upgrade-"));
|
|
154
|
+
try {
|
|
155
|
+
const stagingBinary = join(stagingDir, cliName);
|
|
156
|
+
await Bun.write(stagingBinary, Bun.file(extractedBinary));
|
|
157
|
+
await spawnService.spawn(["chmod", "+x", stagingBinary], { mode: "ignore" });
|
|
158
|
+
await rename(stagingBinary, currentExecutable);
|
|
159
|
+
} finally {
|
|
160
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|