@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.
- package/dist/src/cli/BaseCLI.d.ts.map +1 -1
- package/dist/src/cli/BaseCLI.js +2 -0
- package/dist/src/runtime/lifecycle/BackgroundTasks.d.ts +21 -0
- package/dist/src/runtime/lifecycle/BackgroundTasks.d.ts.map +1 -0
- package/dist/src/runtime/lifecycle/BackgroundTasks.js +69 -0
- package/dist/src/runtime/runner.d.ts +2 -2
- package/dist/src/runtime/runner.d.ts.map +1 -1
- package/dist/src/runtime/runner.js +4 -5
- 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/cli/BaseCLI.ts +2 -0
- package/src/runtime/lifecycle/BackgroundTasks.ts +96 -0
- package/src/runtime/runner.ts +4 -5
- 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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
FetchService,
|
|
6
|
+
PrinterService,
|
|
7
|
+
SpawnService,
|
|
8
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
9
|
+
import semver, { type SemVer } from "semver";
|
|
10
|
+
import type { HomebrewLocation } from "../UpgradeLocationsConfig.ts";
|
|
11
|
+
import { describeSpawnFailure, spawnQuoted, type VersionLookupResult } from "./shared.ts";
|
|
12
|
+
|
|
13
|
+
// `brew list --versions <formula>` prints the formula name followed by one or more installed
|
|
14
|
+
// versions, each optionally prefixed with "v" and optionally suffixed with a "_N" revision.
|
|
15
|
+
export function parseBrewInstalledVersions(output: string, formula: string): SemVer[] {
|
|
16
|
+
const tokens = output.trim().split(/\s+/).filter(Boolean);
|
|
17
|
+
if (tokens[0] === formula) {
|
|
18
|
+
tokens.shift();
|
|
19
|
+
}
|
|
20
|
+
const versions: SemVer[] = [];
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const version = semver.coerce(token.replace(/^v/i, "").replace(/_\d+$/, ""));
|
|
23
|
+
if (version) {
|
|
24
|
+
versions.push(version);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return versions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveRealExecutable(): string {
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(process.execPath);
|
|
33
|
+
} catch {
|
|
34
|
+
// process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
|
|
35
|
+
// the unresolved path rather than treating that as "not installed".
|
|
36
|
+
return process.execPath;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Homebrew relinks a formula's installed binary from its Cellar directory into a `bin/` symlink,
|
|
41
|
+
// so resolving the running executable's real path confirms a homebrew install without spawning
|
|
42
|
+
// `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
|
|
43
|
+
// StartupTask (see UpgradeServiceProvider) fast even though it runs to completion.
|
|
44
|
+
export function isRunningFromHomebrewCellar(formula: string): boolean {
|
|
45
|
+
return resolveRealExecutable().includes(`/Cellar/${formula}/`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The running executable lives in a version-specific Cellar directory, e.g.
|
|
49
|
+
// "<prefix>/Cellar/<formula>/<version>/bin/<exe>", while "<prefix>/opt/<formula>" links to the
|
|
50
|
+
// installed version, so the upgraded executable is "<prefix>/opt/<formula>/bin/<exe>".
|
|
51
|
+
export function resolveHomebrewOptExecutable(
|
|
52
|
+
location: HomebrewLocation | undefined,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (!location) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const { formula } = location;
|
|
58
|
+
const realExecutable = resolveRealExecutable();
|
|
59
|
+
const cellarIndex = realExecutable.indexOf(`/Cellar/${formula}/`);
|
|
60
|
+
if (cellarIndex === -1) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const prefix = realExecutable.slice(0, cellarIndex);
|
|
64
|
+
const executable = join(prefix, "opt", formula, "bin", basename(realExecutable));
|
|
65
|
+
return existsSync(executable) ? executable : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function isHomebrewInstalled(
|
|
69
|
+
spawnService: SpawnService | undefined,
|
|
70
|
+
location: HomebrewLocation | undefined,
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
if (!spawnService || !location) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const result = await spawnService.spawn(["brew", "list", "--versions", location.formula], {
|
|
76
|
+
mode: "ignore",
|
|
77
|
+
longRunning: false,
|
|
78
|
+
});
|
|
79
|
+
return result.ok;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function getLatestHomebrewVersion(
|
|
83
|
+
fetchService: FetchService | undefined,
|
|
84
|
+
location: HomebrewLocation | undefined,
|
|
85
|
+
): Promise<VersionLookupResult> {
|
|
86
|
+
if (!location) {
|
|
87
|
+
return { ok: false, error: new Error("No homebrew location configured") };
|
|
88
|
+
}
|
|
89
|
+
if (!fetchService) {
|
|
90
|
+
return { ok: false, error: new Error("FetchService is not available") };
|
|
91
|
+
}
|
|
92
|
+
const { tap, formula } = location;
|
|
93
|
+
const [tapOwner, tapName] = tap.split("/");
|
|
94
|
+
if (!tapOwner || !tapName) {
|
|
95
|
+
return { ok: false, error: new Error(`Invalid homebrew tap '${tap}'`) };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetchService.fetch(
|
|
99
|
+
`https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`,
|
|
100
|
+
);
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: new Error(
|
|
105
|
+
`Failed to fetch homebrew formula for ${tap}/${formula}: HTTP ${response.status}`,
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const text = await response.text();
|
|
110
|
+
const version = /version\s+"v?([^"]+)"/.exec(text)?.[1];
|
|
111
|
+
if (!version) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: new Error(`Could not parse version from homebrew formula ${tap}/${formula}`),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, version };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: new Error(`Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The latest version is read from the tap's formula on GitHub, but `brew upgrade` only sees the
|
|
127
|
+
// local tap clone, which is stale until `brew update` runs. A stale tap makes `brew upgrade`
|
|
128
|
+
// exit 0 with "already installed", so update first and then confirm the installed version.
|
|
129
|
+
export async function upgradeViaHomebrew(
|
|
130
|
+
spawnService: SpawnService,
|
|
131
|
+
printerService: PrinterService | undefined,
|
|
132
|
+
location: HomebrewLocation,
|
|
133
|
+
expectedVersion: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const { tap, formula } = location;
|
|
136
|
+
const updateResult = await spawnQuoted(spawnService, printerService, ["brew", "update"]);
|
|
137
|
+
if (!updateResult.ok) {
|
|
138
|
+
throw new Error(`brew update failed: ${describeSpawnFailure(updateResult)}`);
|
|
139
|
+
}
|
|
140
|
+
const result = await spawnQuoted(spawnService, printerService, [
|
|
141
|
+
"brew",
|
|
142
|
+
"upgrade",
|
|
143
|
+
`${tap}/${formula}`,
|
|
144
|
+
]);
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
throw new Error(`brew upgrade failed: ${describeSpawnFailure(result)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
const listResult = await spawnService.spawn(["brew", "list", "--versions", formula], {
|
|
151
|
+
mode: "wrapped",
|
|
152
|
+
longRunning: false,
|
|
153
|
+
onOutput: (line) => lines.push(line),
|
|
154
|
+
});
|
|
155
|
+
const installed = lines.join(" ").trim();
|
|
156
|
+
const expected = semver.coerce(expectedVersion);
|
|
157
|
+
const isExpectedInstalled = parseBrewInstalledVersions(installed, formula).some(
|
|
158
|
+
(version) => expected !== null && semver.eq(version, expected),
|
|
159
|
+
);
|
|
160
|
+
if (!listResult.ok || !isExpectedInstalled) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`brew upgrade completed but version ${expectedVersion} is not installed (installed: ${installed || "unknown"})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import type { PrinterService, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
|
+
import type { LinuxScriptLocation } from "../UpgradeLocationsConfig.ts";
|
|
4
|
+
import { describeSpawnFailure, spawnQuoted } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
export function isLinuxScriptInstall(): boolean {
|
|
7
|
+
return process.execPath.startsWith("/usr/local/bin/");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function upgradeViaLinuxScript(
|
|
11
|
+
spawnService: SpawnService,
|
|
12
|
+
printerService: PrinterService | undefined,
|
|
13
|
+
location: LinuxScriptLocation,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const result = await spawnQuoted(spawnService, printerService, [
|
|
16
|
+
"sh",
|
|
17
|
+
"-c",
|
|
18
|
+
`curl -fsSL ${location.scriptUrl} | sh`,
|
|
19
|
+
]);
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
throw new Error(`Install script failed: ${describeSpawnFailure(result)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PrinterService,
|
|
3
|
+
SpawnResult,
|
|
4
|
+
SpawnService,
|
|
5
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
6
|
+
|
|
7
|
+
export type VersionLookupResult =
|
|
8
|
+
| { readonly ok: true; readonly version: string }
|
|
9
|
+
| { readonly ok: false; readonly error: Error };
|
|
10
|
+
|
|
11
|
+
export function describeSpawnFailure(result: Extract<SpawnResult, { ok: false }>): string {
|
|
12
|
+
return "timedOut" in result
|
|
13
|
+
? "timed out"
|
|
14
|
+
: (result.error?.message ?? `exit code ${result.exitCode}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mirrors SpawnInterfaceAdapter's plugin:add/plugin:remove pattern: wrap a spawned command's
|
|
18
|
+
// output in a quoted, marked block that's cleared on success (so a clean install stays quiet)
|
|
19
|
+
// but left on screen on failure (so the diagnostic output remains visible).
|
|
20
|
+
export async function spawnQuoted(
|
|
21
|
+
spawnService: SpawnService,
|
|
22
|
+
printerService: PrinterService | undefined,
|
|
23
|
+
command: ReadonlyArray<string>,
|
|
24
|
+
): Promise<SpawnResult> {
|
|
25
|
+
if (!printerService) {
|
|
26
|
+
return spawnService.spawn(command, { mode: "ignore" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
printerService.startQuote();
|
|
30
|
+
printerService.startMark();
|
|
31
|
+
|
|
32
|
+
// onOutput is synchronous and may be called concurrently for stdout/stderr lines, but
|
|
33
|
+
// printerService.info() is async and must not be invoked concurrently with itself - queue
|
|
34
|
+
// writes so they're applied one at a time, in call order.
|
|
35
|
+
let writeQueue: Promise<void> = Promise.resolve();
|
|
36
|
+
const onOutput = (line: string): void => {
|
|
37
|
+
writeQueue = writeQueue.then(() => printerService.info(`${line}\n`));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const result = await spawnService.spawn(command, { mode: "wrapped", onOutput });
|
|
41
|
+
await writeQueue;
|
|
42
|
+
|
|
43
|
+
printerService.endQuote();
|
|
44
|
+
printerService.endMark();
|
|
45
|
+
if (result.ok) {
|
|
46
|
+
await printerService.clearMarked();
|
|
47
|
+
} else {
|
|
48
|
+
printerService.discardMark();
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PrinterService, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
|
+
import type { WingetLocation } from "../UpgradeLocationsConfig.ts";
|
|
3
|
+
import { describeSpawnFailure, spawnQuoted, type VersionLookupResult } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
export async function isWingetInstalled(
|
|
6
|
+
spawnService: SpawnService | undefined,
|
|
7
|
+
location: WingetLocation | undefined,
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
if (!spawnService || !location) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const result = await spawnService.spawn(["winget", "list", "--id", location.packageId], {
|
|
13
|
+
mode: "ignore",
|
|
14
|
+
longRunning: false,
|
|
15
|
+
});
|
|
16
|
+
return result.ok;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getLatestWingetVersion(
|
|
20
|
+
spawnService: SpawnService | undefined,
|
|
21
|
+
location: WingetLocation | undefined,
|
|
22
|
+
): Promise<VersionLookupResult> {
|
|
23
|
+
if (!location) {
|
|
24
|
+
return { ok: false, error: new Error("No winget location configured") };
|
|
25
|
+
}
|
|
26
|
+
if (!spawnService) {
|
|
27
|
+
return { ok: false, error: new Error("SpawnService is not available") };
|
|
28
|
+
}
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
const result = await spawnService.spawn(["winget", "show", "--id", location.packageId], {
|
|
31
|
+
mode: "wrapped",
|
|
32
|
+
longRunning: false,
|
|
33
|
+
onOutput: (line) => lines.push(line),
|
|
34
|
+
});
|
|
35
|
+
if (!result.ok) {
|
|
36
|
+
return { ok: false, error: new Error(`winget show failed: ${describeSpawnFailure(result)}`) };
|
|
37
|
+
}
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const match = /Version:\s*(\S+)/.exec(line);
|
|
40
|
+
if (match?.[1]) {
|
|
41
|
+
return { ok: true, version: match[1] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ok: false, error: new Error("Could not parse version from winget output") };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function upgradeViaWinget(
|
|
48
|
+
spawnService: SpawnService,
|
|
49
|
+
printerService: PrinterService | undefined,
|
|
50
|
+
location: WingetLocation,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const result = await spawnQuoted(spawnService, printerService, [
|
|
53
|
+
"winget",
|
|
54
|
+
"upgrade",
|
|
55
|
+
"--id",
|
|
56
|
+
location.packageId,
|
|
57
|
+
"--silent",
|
|
58
|
+
"--accept-package-agreements",
|
|
59
|
+
"--accept-source-agreements",
|
|
60
|
+
]);
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
throw new Error(`winget upgrade failed: ${describeSpawnFailure(result)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|