@flowscripter/dynamic-cli-framework 9.0.3 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/src/cli/BaseCLI.d.ts.map +1 -1
  2. package/dist/src/cli/BaseCLI.js +2 -0
  3. package/dist/src/runtime/lifecycle/BackgroundTasks.d.ts +21 -0
  4. package/dist/src/runtime/lifecycle/BackgroundTasks.d.ts.map +1 -0
  5. package/dist/src/runtime/lifecycle/BackgroundTasks.js +69 -0
  6. package/dist/src/runtime/runner.d.ts +2 -2
  7. package/dist/src/runtime/runner.d.ts.map +1 -1
  8. package/dist/src/runtime/runner.js +4 -5
  9. package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
  10. package/dist/src/service/upgrade/DefaultUpgradeService.js +18 -330
  11. package/dist/src/service/upgrade/method/githubRelease.d.ts +6 -0
  12. package/dist/src/service/upgrade/method/githubRelease.d.ts.map +1 -0
  13. package/dist/src/service/upgrade/method/githubRelease.js +132 -0
  14. package/dist/src/service/upgrade/method/homebrew.d.ts +11 -0
  15. package/dist/src/service/upgrade/method/homebrew.d.ts.map +1 -0
  16. package/dist/src/service/upgrade/method/homebrew.js +132 -0
  17. package/dist/src/service/upgrade/method/linuxScript.d.ts +5 -0
  18. package/dist/src/service/upgrade/method/linuxScript.d.ts.map +1 -0
  19. package/dist/src/service/upgrade/method/linuxScript.js +15 -0
  20. package/dist/src/service/upgrade/method/shared.d.ts +13 -0
  21. package/dist/src/service/upgrade/method/shared.d.ts.map +1 -0
  22. package/dist/src/service/upgrade/method/shared.js +33 -0
  23. package/dist/src/service/upgrade/method/winget.d.ts +7 -0
  24. package/dist/src/service/upgrade/method/winget.d.ts.map +1 -0
  25. package/dist/src/service/upgrade/method/winget.js +49 -0
  26. package/package.json +1 -1
  27. package/src/cli/BaseCLI.ts +2 -0
  28. package/src/runtime/lifecycle/BackgroundTasks.ts +96 -0
  29. package/src/runtime/runner.ts +4 -5
  30. package/src/service/upgrade/DefaultUpgradeService.ts +38 -377
  31. package/src/service/upgrade/method/githubRelease.ts +163 -0
  32. package/src/service/upgrade/method/homebrew.ts +165 -0
  33. package/src/service/upgrade/method/linuxScript.ts +23 -0
  34. package/src/service/upgrade/method/shared.ts +51 -0
  35. package/src/service/upgrade/method/winget.ts +64 -0
@@ -0,0 +1,132 @@
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 { SupportedArch, SupportedOs, } from "@flowscripter/dynamic-cli-framework-api";
6
+ import { spawnQuoted } from "./shared.js";
7
+ const OS_LABELS = {
8
+ [SupportedOs.LINUX]: "Linux",
9
+ [SupportedOs.MACOS]: "MacOS",
10
+ [SupportedOs.WINDOWS]: "Windows",
11
+ };
12
+ export async function getLatestGithubReleaseVersion(fetchService, location) {
13
+ if (!location) {
14
+ return { ok: false, error: new Error("No githubRelease location configured") };
15
+ }
16
+ if (!fetchService) {
17
+ return { ok: false, error: new Error("FetchService is not available") };
18
+ }
19
+ const { owner, repo } = location;
20
+ try {
21
+ // Uses the plain web redirect rather than the api.github.com REST endpoint, since the
22
+ // latter's unauthenticated rate limit (60 requests/hour/IP) is easily exhausted, e.g. by
23
+ // CI runners sharing an IP pool.
24
+ const response = await fetchService.fetch(`https://github.com/${owner}/${repo}/releases/latest`, { redirect: "manual" });
25
+ const redirect = response.headers.get("location");
26
+ const version = redirect ? /\/releases\/tag\/v?([^/]+)$/.exec(redirect)?.[1] : undefined;
27
+ if (!version) {
28
+ return {
29
+ ok: false,
30
+ error: new Error(`Unexpected response resolving latest release for ${owner}/${repo}: HTTP ${response.status}`),
31
+ };
32
+ }
33
+ return { ok: true, version };
34
+ }
35
+ catch (error) {
36
+ return {
37
+ ok: false,
38
+ error: new Error(`Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`),
39
+ };
40
+ }
41
+ }
42
+ export async function upgradeViaGithubRelease(spawnService, fetchService, printerService, location, cliName, os, arch) {
43
+ const { owner, repo, assetPattern } = location;
44
+ // macOS release assets use "aarch64" rather than "arm64" for the arm64 build; x64 (including
45
+ // Intel Macs) always uses "x64" regardless of os.
46
+ const archLabel = arch === SupportedArch.X64 ? "x64" : os === SupportedOs.MACOS ? "aarch64" : "arm64";
47
+ const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
48
+ const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
49
+ // longRunning: true gets cooperative Ctrl-C handling during what can be the slowest step of
50
+ // the upgrade.
51
+ const response = await fetchService.fetch(url, { longRunning: true });
52
+ if (!response.ok) {
53
+ throw new Error(`Failed to download release asset '${assetName}': HTTP ${response.status}`);
54
+ }
55
+ const archiveData = await response.arrayBuffer();
56
+ const tmpDir = await mkdtemp(join(tmpdir(), "upgrade-"));
57
+ const archivePath = join(tmpDir, assetName);
58
+ await Bun.write(archivePath, archiveData);
59
+ const currentExecutable = process.execPath;
60
+ if (os === SupportedOs.WINDOWS) {
61
+ const extractResult = await spawnQuoted(spawnService, printerService, [
62
+ "powershell",
63
+ "-Command",
64
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
65
+ ]);
66
+ if (!extractResult.ok) {
67
+ throw new Error("Failed to extract release archive");
68
+ }
69
+ const extractedBinary = join(tmpDir, `${cliName}.exe`);
70
+ const oldPath = `${currentExecutable}.old.exe`;
71
+ // Best-effort cleanup of a stale "<exe>.old.exe" left behind by a *previous* upgrade run.
72
+ // Windows won't let us delete the just-renamed-aside exe while this process still has it
73
+ // open/mapped - that can only happen once we're no longer holding it, i.e. at the start of
74
+ // the NEXT invocation, before we move today's running exe aside. Ignore failures: the file
75
+ // may not exist, or may still be locked (e.g. another instance still running). Not quoted -
76
+ // this is expected to fail silently, there's nothing worth showing the user.
77
+ await spawnService.spawn(["cmd", "/c", "del", "/f", "/q", oldPath], {
78
+ mode: "ignore",
79
+ });
80
+ const moveResult = await spawnQuoted(spawnService, printerService, [
81
+ "cmd",
82
+ "/c",
83
+ "move",
84
+ "/y",
85
+ currentExecutable,
86
+ oldPath,
87
+ ]);
88
+ if (!moveResult.ok) {
89
+ throw new Error("Failed to move current executable aside");
90
+ }
91
+ const copyResult = await spawnQuoted(spawnService, printerService, [
92
+ "cmd",
93
+ "/c",
94
+ "copy",
95
+ "/y",
96
+ extractedBinary,
97
+ currentExecutable,
98
+ ]);
99
+ if (!copyResult.ok) {
100
+ throw new Error("Failed to copy new executable into place");
101
+ }
102
+ }
103
+ else {
104
+ const extractResult = await spawnQuoted(spawnService, printerService, [
105
+ "unzip",
106
+ "-o",
107
+ archivePath,
108
+ "-d",
109
+ tmpDir,
110
+ ]);
111
+ if (!extractResult.ok) {
112
+ throw new Error("Failed to extract release archive");
113
+ }
114
+ const extractedBinary = join(tmpDir, cliName);
115
+ // Extract into a staging file in the SAME directory as the running executable (not
116
+ // os.tmpdir(), which may be a different filesystem/mount), then atomically rename it over
117
+ // currentExecutable. This avoids ETXTBSY: the kernel refuses to open-for-write the inode
118
+ // mapped as a running process's text segment, but rename() only swaps the directory entry
119
+ // to point at a different inode - the running process keeps executing from its original,
120
+ // now-unlinked-but-still-open inode until it next execs/restarts.
121
+ const stagingDir = await mkdtemp(join(dirname(currentExecutable), ".upgrade-"));
122
+ try {
123
+ const stagingBinary = join(stagingDir, cliName);
124
+ await Bun.write(stagingBinary, Bun.file(extractedBinary));
125
+ await spawnService.spawn(["chmod", "+x", stagingBinary], { mode: "ignore" });
126
+ await rename(stagingBinary, currentExecutable);
127
+ }
128
+ finally {
129
+ await rm(stagingDir, { recursive: true, force: true });
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,11 @@
1
+ import type { FetchService, PrinterService, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
2
+ import { type SemVer } from "semver";
3
+ import type { HomebrewLocation } from "../UpgradeLocationsConfig.ts";
4
+ import { type VersionLookupResult } from "./shared.ts";
5
+ export declare function parseBrewInstalledVersions(output: string, formula: string): SemVer[];
6
+ export declare function isRunningFromHomebrewCellar(formula: string): boolean;
7
+ export declare function resolveHomebrewOptExecutable(location: HomebrewLocation | undefined): string | undefined;
8
+ export declare function isHomebrewInstalled(spawnService: SpawnService | undefined, location: HomebrewLocation | undefined): Promise<boolean>;
9
+ export declare function getLatestHomebrewVersion(fetchService: FetchService | undefined, location: HomebrewLocation | undefined): Promise<VersionLookupResult>;
10
+ export declare function upgradeViaHomebrew(spawnService: SpawnService, printerService: PrinterService | undefined, location: HomebrewLocation, expectedVersion: string): Promise<void>;
11
+ //# sourceMappingURL=homebrew.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"homebrew.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/method/homebrew.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,YAAY,EACZ,cAAc,EACd,YAAY,EACb,MAAM,yCAAyC,CAAC;AACjD,OAAe,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAqC,KAAK,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAI1F,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAapF;AAgBD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEpE;AAKD,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,gBAAgB,GAAG,SAAS,GACrC,MAAM,GAAG,SAAS,CAapB;AAED,wBAAsB,mBAAmB,CACvC,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,QAAQ,EAAE,gBAAgB,GAAG,SAAS,GACrC,OAAO,CAAC,OAAO,CAAC,CASlB;AAED,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,QAAQ,EAAE,gBAAgB,GAAG,SAAS,GACrC,OAAO,CAAC,mBAAmB,CAAC,CAuC9B;AAKD,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,GAAG,SAAS,EAC1C,QAAQ,EAAE,gBAAgB,EAC1B,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
@@ -0,0 +1,132 @@
1
+ import process from "node:process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import semver, {} from "semver";
5
+ import { describeSpawnFailure, spawnQuoted } from "./shared.js";
6
+ // `brew list --versions <formula>` prints the formula name followed by one or more installed
7
+ // versions, each optionally prefixed with "v" and optionally suffixed with a "_N" revision.
8
+ export function parseBrewInstalledVersions(output, formula) {
9
+ const tokens = output.trim().split(/\s+/).filter(Boolean);
10
+ if (tokens[0] === formula) {
11
+ tokens.shift();
12
+ }
13
+ const versions = [];
14
+ for (const token of tokens) {
15
+ const version = semver.coerce(token.replace(/^v/i, "").replace(/_\d+$/, ""));
16
+ if (version) {
17
+ versions.push(version);
18
+ }
19
+ }
20
+ return versions;
21
+ }
22
+ function resolveRealExecutable() {
23
+ try {
24
+ return realpathSync(process.execPath);
25
+ }
26
+ catch {
27
+ // process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
28
+ // the unresolved path rather than treating that as "not installed".
29
+ return process.execPath;
30
+ }
31
+ }
32
+ // Homebrew relinks a formula's installed binary from its Cellar directory into a `bin/` symlink,
33
+ // so resolving the running executable's real path confirms a homebrew install without spawning
34
+ // `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
35
+ // StartupTask (see UpgradeServiceProvider) fast even though it runs to completion.
36
+ export function isRunningFromHomebrewCellar(formula) {
37
+ return resolveRealExecutable().includes(`/Cellar/${formula}/`);
38
+ }
39
+ // The running executable lives in a version-specific Cellar directory, e.g.
40
+ // "<prefix>/Cellar/<formula>/<version>/bin/<exe>", while "<prefix>/opt/<formula>" links to the
41
+ // installed version, so the upgraded executable is "<prefix>/opt/<formula>/bin/<exe>".
42
+ export function resolveHomebrewOptExecutable(location) {
43
+ if (!location) {
44
+ return undefined;
45
+ }
46
+ const { formula } = location;
47
+ const realExecutable = resolveRealExecutable();
48
+ const cellarIndex = realExecutable.indexOf(`/Cellar/${formula}/`);
49
+ if (cellarIndex === -1) {
50
+ return undefined;
51
+ }
52
+ const prefix = realExecutable.slice(0, cellarIndex);
53
+ const executable = join(prefix, "opt", formula, "bin", basename(realExecutable));
54
+ return existsSync(executable) ? executable : undefined;
55
+ }
56
+ export async function isHomebrewInstalled(spawnService, location) {
57
+ if (!spawnService || !location) {
58
+ return false;
59
+ }
60
+ const result = await spawnService.spawn(["brew", "list", "--versions", location.formula], {
61
+ mode: "ignore",
62
+ longRunning: false,
63
+ });
64
+ return result.ok;
65
+ }
66
+ export async function getLatestHomebrewVersion(fetchService, location) {
67
+ if (!location) {
68
+ return { ok: false, error: new Error("No homebrew location configured") };
69
+ }
70
+ if (!fetchService) {
71
+ return { ok: false, error: new Error("FetchService is not available") };
72
+ }
73
+ const { tap, formula } = location;
74
+ const [tapOwner, tapName] = tap.split("/");
75
+ if (!tapOwner || !tapName) {
76
+ return { ok: false, error: new Error(`Invalid homebrew tap '${tap}'`) };
77
+ }
78
+ try {
79
+ const response = await fetchService.fetch(`https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`);
80
+ if (!response.ok) {
81
+ return {
82
+ ok: false,
83
+ error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: HTTP ${response.status}`),
84
+ };
85
+ }
86
+ const text = await response.text();
87
+ const version = /version\s+"v?([^"]+)"/.exec(text)?.[1];
88
+ if (!version) {
89
+ return {
90
+ ok: false,
91
+ error: new Error(`Could not parse version from homebrew formula ${tap}/${formula}`),
92
+ };
93
+ }
94
+ return { ok: true, version };
95
+ }
96
+ catch (error) {
97
+ return {
98
+ ok: false,
99
+ error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`),
100
+ };
101
+ }
102
+ }
103
+ // The latest version is read from the tap's formula on GitHub, but `brew upgrade` only sees the
104
+ // local tap clone, which is stale until `brew update` runs. A stale tap makes `brew upgrade`
105
+ // exit 0 with "already installed", so update first and then confirm the installed version.
106
+ export async function upgradeViaHomebrew(spawnService, printerService, location, expectedVersion) {
107
+ const { tap, formula } = location;
108
+ const updateResult = await spawnQuoted(spawnService, printerService, ["brew", "update"]);
109
+ if (!updateResult.ok) {
110
+ throw new Error(`brew update failed: ${describeSpawnFailure(updateResult)}`);
111
+ }
112
+ const result = await spawnQuoted(spawnService, printerService, [
113
+ "brew",
114
+ "upgrade",
115
+ `${tap}/${formula}`,
116
+ ]);
117
+ if (!result.ok) {
118
+ throw new Error(`brew upgrade failed: ${describeSpawnFailure(result)}`);
119
+ }
120
+ const lines = [];
121
+ const listResult = await spawnService.spawn(["brew", "list", "--versions", formula], {
122
+ mode: "wrapped",
123
+ longRunning: false,
124
+ onOutput: (line) => lines.push(line),
125
+ });
126
+ const installed = lines.join(" ").trim();
127
+ const expected = semver.coerce(expectedVersion);
128
+ const isExpectedInstalled = parseBrewInstalledVersions(installed, formula).some((version) => expected !== null && semver.eq(version, expected));
129
+ if (!listResult.ok || !isExpectedInstalled) {
130
+ throw new Error(`brew upgrade completed but version ${expectedVersion} is not installed (installed: ${installed || "unknown"})`);
131
+ }
132
+ }
@@ -0,0 +1,5 @@
1
+ import type { PrinterService, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
2
+ import type { LinuxScriptLocation } from "../UpgradeLocationsConfig.ts";
3
+ export declare function isLinuxScriptInstall(): boolean;
4
+ export declare function upgradeViaLinuxScript(spawnService: SpawnService, printerService: PrinterService | undefined, location: LinuxScriptLocation): Promise<void>;
5
+ //# sourceMappingURL=linuxScript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linuxScript.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/method/linuxScript.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAGxE,wBAAgB,oBAAoB,IAAI,OAAO,CAE9C;AAED,wBAAsB,qBAAqB,CACzC,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,GAAG,SAAS,EAC1C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAAC,IAAI,CAAC,CASf"}
@@ -0,0 +1,15 @@
1
+ import process from "node:process";
2
+ import { describeSpawnFailure, spawnQuoted } from "./shared.js";
3
+ export function isLinuxScriptInstall() {
4
+ return process.execPath.startsWith("/usr/local/bin/");
5
+ }
6
+ export async function upgradeViaLinuxScript(spawnService, printerService, location) {
7
+ const result = await spawnQuoted(spawnService, printerService, [
8
+ "sh",
9
+ "-c",
10
+ `curl -fsSL ${location.scriptUrl} | sh`,
11
+ ]);
12
+ if (!result.ok) {
13
+ throw new Error(`Install script failed: ${describeSpawnFailure(result)}`);
14
+ }
15
+ }
@@ -0,0 +1,13 @@
1
+ import type { PrinterService, SpawnResult, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
2
+ export type VersionLookupResult = {
3
+ readonly ok: true;
4
+ readonly version: string;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly error: Error;
8
+ };
9
+ export declare function describeSpawnFailure(result: Extract<SpawnResult, {
10
+ ok: false;
11
+ }>): string;
12
+ export declare function spawnQuoted(spawnService: SpawnService, printerService: PrinterService | undefined, command: ReadonlyArray<string>): Promise<SpawnResult>;
13
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/method/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,WAAW,EACX,YAAY,EACb,MAAM,yCAAyC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAC3B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAElD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC,GAAG,MAAM,CAIxF;AAKD,wBAAsB,WAAW,CAC/B,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,GAAG,SAAS,EAC1C,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,GAC7B,OAAO,CAAC,WAAW,CAAC,CA2BtB"}
@@ -0,0 +1,33 @@
1
+ export function describeSpawnFailure(result) {
2
+ return "timedOut" in result
3
+ ? "timed out"
4
+ : (result.error?.message ?? `exit code ${result.exitCode}`);
5
+ }
6
+ // Mirrors SpawnInterfaceAdapter's plugin:add/plugin:remove pattern: wrap a spawned command's
7
+ // output in a quoted, marked block that's cleared on success (so a clean install stays quiet)
8
+ // but left on screen on failure (so the diagnostic output remains visible).
9
+ export async function spawnQuoted(spawnService, printerService, command) {
10
+ if (!printerService) {
11
+ return spawnService.spawn(command, { mode: "ignore" });
12
+ }
13
+ printerService.startQuote();
14
+ printerService.startMark();
15
+ // onOutput is synchronous and may be called concurrently for stdout/stderr lines, but
16
+ // printerService.info() is async and must not be invoked concurrently with itself - queue
17
+ // writes so they're applied one at a time, in call order.
18
+ let writeQueue = Promise.resolve();
19
+ const onOutput = (line) => {
20
+ writeQueue = writeQueue.then(() => printerService.info(`${line}\n`));
21
+ };
22
+ const result = await spawnService.spawn(command, { mode: "wrapped", onOutput });
23
+ await writeQueue;
24
+ printerService.endQuote();
25
+ printerService.endMark();
26
+ if (result.ok) {
27
+ await printerService.clearMarked();
28
+ }
29
+ else {
30
+ printerService.discardMark();
31
+ }
32
+ return result;
33
+ }
@@ -0,0 +1,7 @@
1
+ import type { PrinterService, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
2
+ import type { WingetLocation } from "../UpgradeLocationsConfig.ts";
3
+ import { type VersionLookupResult } from "./shared.ts";
4
+ export declare function isWingetInstalled(spawnService: SpawnService | undefined, location: WingetLocation | undefined): Promise<boolean>;
5
+ export declare function getLatestWingetVersion(spawnService: SpawnService | undefined, location: WingetLocation | undefined): Promise<VersionLookupResult>;
6
+ export declare function upgradeViaWinget(spawnService: SpawnService, printerService: PrinterService | undefined, location: WingetLocation): Promise<void>;
7
+ //# sourceMappingURL=winget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"winget.d.ts","sourceRoot":"","sources":["../../../../../src/service/upgrade/method/winget.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,yCAAyC,CAAC;AAC5F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAqC,KAAK,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAE1F,wBAAsB,iBAAiB,CACrC,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,QAAQ,EAAE,cAAc,GAAG,SAAS,GACnC,OAAO,CAAC,OAAO,CAAC,CASlB;AAED,wBAAsB,sBAAsB,CAC1C,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,QAAQ,EAAE,cAAc,GAAG,SAAS,GACnC,OAAO,CAAC,mBAAmB,CAAC,CAuB9B;AAED,wBAAsB,gBAAgB,CACpC,YAAY,EAAE,YAAY,EAC1B,cAAc,EAAE,cAAc,GAAG,SAAS,EAC1C,QAAQ,EAAE,cAAc,GACvB,OAAO,CAAC,IAAI,CAAC,CAaf"}
@@ -0,0 +1,49 @@
1
+ import { describeSpawnFailure, spawnQuoted } from "./shared.js";
2
+ export async function isWingetInstalled(spawnService, location) {
3
+ if (!spawnService || !location) {
4
+ return false;
5
+ }
6
+ const result = await spawnService.spawn(["winget", "list", "--id", location.packageId], {
7
+ mode: "ignore",
8
+ longRunning: false,
9
+ });
10
+ return result.ok;
11
+ }
12
+ export async function getLatestWingetVersion(spawnService, location) {
13
+ if (!location) {
14
+ return { ok: false, error: new Error("No winget location configured") };
15
+ }
16
+ if (!spawnService) {
17
+ return { ok: false, error: new Error("SpawnService is not available") };
18
+ }
19
+ const lines = [];
20
+ const result = await spawnService.spawn(["winget", "show", "--id", location.packageId], {
21
+ mode: "wrapped",
22
+ longRunning: false,
23
+ onOutput: (line) => lines.push(line),
24
+ });
25
+ if (!result.ok) {
26
+ return { ok: false, error: new Error(`winget show failed: ${describeSpawnFailure(result)}`) };
27
+ }
28
+ for (const line of lines) {
29
+ const match = /Version:\s*(\S+)/.exec(line);
30
+ if (match?.[1]) {
31
+ return { ok: true, version: match[1] };
32
+ }
33
+ }
34
+ return { ok: false, error: new Error("Could not parse version from winget output") };
35
+ }
36
+ export async function upgradeViaWinget(spawnService, printerService, location) {
37
+ const result = await spawnQuoted(spawnService, printerService, [
38
+ "winget",
39
+ "upgrade",
40
+ "--id",
41
+ location.packageId,
42
+ "--silent",
43
+ "--accept-package-agreements",
44
+ "--accept-source-agreements",
45
+ ]);
46
+ if (!result.ok) {
47
+ throw new Error(`winget upgrade failed: ${describeSpawnFailure(result)}`);
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowscripter/dynamic-cli-framework",
3
- "version": "9.0.3",
3
+ "version": "9.1.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",
@@ -36,6 +36,7 @@ import KeyValueServiceProvider from "../service/configuration/KeyValueServicePro
36
36
  import PrinterServiceProvider from "../service/printer/PrinterServiceProvider.ts";
37
37
  import TableGeneratorServiceProvider from "../service/tableGenerator/TableGeneratorServiceProvider.ts";
38
38
  import { run } from "../runtime/runner.ts";
39
+ import { awaitBackgroundTasks } from "../runtime/lifecycle/BackgroundTasks.ts";
39
40
  import { printRuntimeError } from "../util/runnerHelper.ts";
40
41
 
41
42
  import { WritableStream } from "node:stream/web";
@@ -510,6 +511,7 @@ export default class BaseCLI implements CLI {
510
511
  error: error as Error,
511
512
  };
512
513
  } finally {
514
+ await awaitBackgroundTasks();
513
515
  await ShutdownServiceProvider.shutdown();
514
516
  }
515
517
  }
@@ -0,0 +1,96 @@
1
+ import getLogger from "../../util/logger.ts";
2
+
3
+ const logger = getLogger("BackgroundTasks");
4
+
5
+ /**
6
+ * How long, at shutdown, to wait for in-flight "background" startup tasks before abandoning them.
7
+ */
8
+ export const BACKGROUND_TASK_GRACE_PERIOD_MS = 1000;
9
+
10
+ interface InFlightTask {
11
+ readonly id: string;
12
+ readonly startedAt: number;
13
+ settled: Promise<void>;
14
+ }
15
+
16
+ const inFlightTasks = new Set<InFlightTask>();
17
+
18
+ function elapsedMs(task: InFlightTask): number {
19
+ return Math.round(performance.now() - task.startedAt);
20
+ }
21
+
22
+ /**
23
+ * Start a background startup task without awaiting it, and track it so that
24
+ * {@link awaitBackgroundTasks} can give it a chance to finish at shutdown. A rejection is
25
+ * logged (at debug level) and never propagated.
26
+ *
27
+ * @param id the id of the startup task.
28
+ * @param start starts the task.
29
+ */
30
+ export function trackBackgroundTask(id: string, start: () => Promise<unknown>): void {
31
+ const task: InFlightTask = { id, startedAt: performance.now(), settled: Promise.resolve() };
32
+ logger.debug(() => `Background startup task '${id}' started`);
33
+ task.settled = new Promise<unknown>((resolve) => resolve(start()))
34
+ .then(
35
+ () => {
36
+ logger.debug(() => `Background startup task '${id}' completed in ${elapsedMs(task)}ms`);
37
+ },
38
+ (error: unknown) => {
39
+ logger.debug(
40
+ () => `Background startup task '${id}' failed after ${elapsedMs(task)}ms: ${error}`,
41
+ );
42
+ },
43
+ )
44
+ .finally(() => {
45
+ inFlightTasks.delete(task);
46
+ });
47
+ inFlightTasks.add(task);
48
+ }
49
+
50
+ /**
51
+ * Wait for the tracked background startup tasks, for at most `gracePeriodMs`. Tasks still running
52
+ * once it expires are abandoned. Returns immediately if no tasks are in flight. Never rejects.
53
+ *
54
+ * @param gracePeriodMs the maximum time to wait.
55
+ */
56
+ export async function awaitBackgroundTasks(
57
+ gracePeriodMs: number = BACKGROUND_TASK_GRACE_PERIOD_MS,
58
+ ): Promise<void> {
59
+ if (inFlightTasks.size === 0) {
60
+ return;
61
+ }
62
+ const pending = [...inFlightTasks];
63
+ logger.debug(
64
+ () =>
65
+ `Waiting up to ${gracePeriodMs}ms for ${pending.length} in-flight background startup task(s)`,
66
+ );
67
+ for (const task of pending) {
68
+ logger.debug(
69
+ () => `Background startup task '${task.id}' still in flight after ${elapsedMs(task)}ms`,
70
+ );
71
+ }
72
+
73
+ let timer: ReturnType<typeof setTimeout> | undefined;
74
+ const expired = new Promise<void>((resolve) => {
75
+ timer = setTimeout(resolve, gracePeriodMs);
76
+ // must not keep the process alive
77
+ (timer as { unref?: () => void }).unref?.();
78
+ });
79
+ try {
80
+ await Promise.race([Promise.all(pending.map((task) => task.settled)), expired]);
81
+ } catch {
82
+ // settled promises never reject; nothing to do
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+
87
+ for (const task of pending) {
88
+ if (inFlightTasks.has(task)) {
89
+ inFlightTasks.delete(task);
90
+ logger.debug(
91
+ () =>
92
+ `Background startup task '${task.id}' cancelled after ${elapsedMs(task)}ms: still running when the ${gracePeriodMs}ms grace period expired`,
93
+ );
94
+ }
95
+ }
96
+ }
@@ -33,6 +33,7 @@ import type { StartupTask } from "@flowscripter/dynamic-cli-framework-api";
33
33
  import type { ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
34
34
  import DefaultStartupService from "../service/startup/DefaultStartupService.ts";
35
35
  import { sortByPriority } from "./lifecycle/PriorityTaskList.ts";
36
+ import { trackBackgroundTask } from "./lifecycle/BackgroundTasks.ts";
36
37
 
37
38
  const logger = getLogger("runner");
38
39
 
@@ -564,8 +565,8 @@ async function findAndExecuteDefaultNonModifierCommand(
564
565
  * - Give the task a Context whose KeyValueService is bound permanently to its own scope
565
566
  * (keyed by task.id), via KeyValueServiceProvider if provided.
566
567
  * - Run the task: awaited before continuing if its mode is "blocking" (the default, and how
567
- * every ServiceProvider's initService() behaves); fired without awaiting if "background"
568
- * (errors logged, not propagated).
568
+ * every ServiceProvider's initService() behaves); started without awaiting if "background"
569
+ * (errors logged, not propagated; the CLI gives it a short grace period to finish at shutdown).
569
570
  * - Return immediately if a "blocking" task returned an exitRequest, using its runState and
570
571
  * skipping the remaining tasks and the command.
571
572
  * 2. Scan arguments for any GlobalModifierCommand clauses not provided by a StartupTask
@@ -698,9 +699,7 @@ export async function run(
698
699
  return { runState: outcome.exitRequest.runState, exitRequested: true };
699
700
  }
700
701
  } else {
701
- void task.run(taskContext).catch((error: unknown) => {
702
- logger.debug(() => `Background startup task '${task.id}' failed: ${error}`);
703
- });
702
+ trackBackgroundTask(task.id, () => task.run(taskContext));
704
703
  }
705
704
  }
706
705