@flowscripter/dynamic-cli-framework 2.2.0 → 2.3.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 +2 -0
- package/dist/src/cli/BaseCLI.d.ts.map +1 -1
- package/dist/src/cli/BaseCLI.js +8 -0
- package/dist/src/cli/BaseCLIFeatureOptions.d.ts +3 -0
- package/dist/src/cli/BaseCLIFeatureOptions.d.ts.map +1 -1
- package/dist/src/command/VersionCommand.d.ts.map +1 -1
- package/dist/src/command/VersionCommand.js +10 -1
- package/dist/src/service/banner/BannerServiceProvider.d.ts.map +1 -1
- package/dist/src/service/banner/BannerServiceProvider.js +8 -0
- package/dist/src/service/spawn/DefaultSpawnService.d.ts.map +1 -1
- package/dist/src/service/spawn/DefaultSpawnService.js +1 -0
- package/dist/src/service/upgrade/DefaultUpgradeService.d.ts +14 -0
- package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -0
- package/dist/src/service/upgrade/DefaultUpgradeService.js +312 -0
- package/dist/src/service/upgrade/UpgradeLocationsConfig.d.ts +51 -0
- package/dist/src/service/upgrade/UpgradeLocationsConfig.d.ts.map +1 -0
- package/dist/src/service/upgrade/UpgradeLocationsConfig.js +0 -0
- package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts +13 -0
- package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts.map +1 -0
- package/dist/src/service/upgrade/UpgradeServiceProvider.js +120 -0
- package/dist/src/service/upgrade/command/UpgradeSubCommand.d.ts +16 -0
- package/dist/src/service/upgrade/command/UpgradeSubCommand.d.ts.map +1 -0
- package/dist/src/service/upgrade/command/UpgradeSubCommand.js +50 -0
- package/package.json +4 -2
- package/src/cli/BaseCLI.ts +9 -0
- package/src/cli/BaseCLIFeatureOptions.ts +4 -0
- package/src/command/VersionCommand.ts +13 -1
- package/src/service/banner/BannerServiceProvider.ts +13 -0
- package/src/service/spawn/DefaultSpawnService.ts +1 -0
- package/src/service/upgrade/DefaultUpgradeService.ts +379 -0
- package/src/service/upgrade/UpgradeLocationsConfig.ts +53 -0
- package/src/service/upgrade/UpgradeServiceProvider.ts +150 -0
- package/src/service/upgrade/command/UpgradeSubCommand.ts +85 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
CLIConfig,
|
|
7
|
+
SpawnService,
|
|
8
|
+
UpgradeCheckResult,
|
|
9
|
+
UpgradeResult,
|
|
10
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
11
|
+
import {
|
|
12
|
+
InstallMethod,
|
|
13
|
+
SupportedArch,
|
|
14
|
+
SupportedOs,
|
|
15
|
+
type UpgradeService,
|
|
16
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
17
|
+
import semver from "semver";
|
|
18
|
+
import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
|
|
19
|
+
import getLogger from "../../util/logger.ts";
|
|
20
|
+
|
|
21
|
+
const logger = getLogger("DefaultUpgradeService");
|
|
22
|
+
|
|
23
|
+
const OS_LABELS: Record<SupportedOs, string> = {
|
|
24
|
+
[SupportedOs.LINUX]: "Linux",
|
|
25
|
+
[SupportedOs.MACOS]: "MacOS",
|
|
26
|
+
[SupportedOs.WINDOWS]: "Windows",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default class DefaultUpgradeService implements UpgradeService {
|
|
30
|
+
#spawnService: SpawnService | undefined;
|
|
31
|
+
readonly #config: UpgradeLocationsConfig;
|
|
32
|
+
readonly #cliConfig: CLIConfig;
|
|
33
|
+
|
|
34
|
+
public constructor(config: UpgradeLocationsConfig, cliConfig: CLIConfig) {
|
|
35
|
+
this.#config = config;
|
|
36
|
+
this.#cliConfig = cliConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public setDependencies(spawnService: SpawnService): void {
|
|
40
|
+
this.#spawnService = spawnService;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public detectOs(): SupportedOs | undefined {
|
|
44
|
+
switch (process.platform) {
|
|
45
|
+
case "linux":
|
|
46
|
+
return SupportedOs.LINUX;
|
|
47
|
+
case "darwin":
|
|
48
|
+
return SupportedOs.MACOS;
|
|
49
|
+
case "win32":
|
|
50
|
+
return SupportedOs.WINDOWS;
|
|
51
|
+
default:
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public detectArch(): SupportedArch | undefined {
|
|
57
|
+
switch (process.arch) {
|
|
58
|
+
case "x64":
|
|
59
|
+
return SupportedArch.X64;
|
|
60
|
+
case "arm64":
|
|
61
|
+
return SupportedArch.ARM64;
|
|
62
|
+
default:
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public async detectInstallMethod(os: SupportedOs): Promise<InstallMethod | undefined> {
|
|
68
|
+
if (os === SupportedOs.MACOS && this.#config.homebrew && (await this.#isHomebrewInstalled())) {
|
|
69
|
+
return InstallMethod.HOMEBREW;
|
|
70
|
+
}
|
|
71
|
+
if (os === SupportedOs.WINDOWS && this.#config.winget && (await this.#isWingetInstalled())) {
|
|
72
|
+
return InstallMethod.WINGET;
|
|
73
|
+
}
|
|
74
|
+
if (os === SupportedOs.LINUX && this.#config.linuxScript && this.#isLinuxScriptInstall()) {
|
|
75
|
+
return InstallMethod.LINUX_SCRIPT;
|
|
76
|
+
}
|
|
77
|
+
if (this.#config.githubRelease) {
|
|
78
|
+
return InstallMethod.GITHUB_RELEASE;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public async checkForUpgrade(
|
|
84
|
+
osOverride?: SupportedOs,
|
|
85
|
+
archOverride?: SupportedArch,
|
|
86
|
+
installMethodOverride?: InstallMethod,
|
|
87
|
+
): Promise<UpgradeCheckResult | undefined> {
|
|
88
|
+
const os = osOverride ?? this.detectOs();
|
|
89
|
+
const arch = archOverride ?? this.detectArch();
|
|
90
|
+
if (!os || !arch || !this.#isPlatformSupported(os, arch)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const installMethod = installMethodOverride ?? (await this.detectInstallMethod(os));
|
|
95
|
+
if (!installMethod) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const latestVersion = await this.#getLatestVersion(installMethod);
|
|
100
|
+
if (!latestVersion) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const currentVersion = this.#cliConfig.version;
|
|
105
|
+
const coercedCurrent = semver.coerce(currentVersion);
|
|
106
|
+
const coercedLatest = semver.coerce(latestVersion);
|
|
107
|
+
if (!coercedCurrent || !coercedLatest) {
|
|
108
|
+
logger.debug(() => `Unable to compare versions '${currentVersion}' and '${latestVersion}'`);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
currentVersion,
|
|
114
|
+
latestVersion,
|
|
115
|
+
updateAvailable: semver.gt(coercedLatest, coercedCurrent),
|
|
116
|
+
os,
|
|
117
|
+
arch,
|
|
118
|
+
installMethod,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async upgrade(
|
|
123
|
+
osOverride?: SupportedOs,
|
|
124
|
+
archOverride?: SupportedArch,
|
|
125
|
+
installMethodOverride?: InstallMethod,
|
|
126
|
+
): Promise<UpgradeResult> {
|
|
127
|
+
const oldVersion = this.#cliConfig.version;
|
|
128
|
+
const checkResult = await this.checkForUpgrade(osOverride, archOverride, installMethodOverride);
|
|
129
|
+
if (!checkResult) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
oldVersion,
|
|
133
|
+
error: new Error("No upgrade location configured for the detected or requested platform"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!this.#spawnService) {
|
|
137
|
+
return { ok: false, oldVersion, error: new Error("SpawnService is not available") };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
switch (checkResult.installMethod) {
|
|
142
|
+
case InstallMethod.LINUX_SCRIPT:
|
|
143
|
+
await this.#upgradeViaLinuxScript();
|
|
144
|
+
break;
|
|
145
|
+
case InstallMethod.HOMEBREW:
|
|
146
|
+
await this.#upgradeViaHomebrew();
|
|
147
|
+
break;
|
|
148
|
+
case InstallMethod.WINGET:
|
|
149
|
+
await this.#upgradeViaWinget();
|
|
150
|
+
break;
|
|
151
|
+
case InstallMethod.GITHUB_RELEASE:
|
|
152
|
+
await this.#upgradeViaGithubRelease(checkResult.os, checkResult.arch);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, oldVersion, newVersion: checkResult.latestVersion };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { ok: false, oldVersion, error: error as Error };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#isPlatformSupported(os: SupportedOs, arch: SupportedArch): boolean {
|
|
162
|
+
return this.#config.supportedPlatforms.some(
|
|
163
|
+
(platform) => platform.os === os && platform.arch === arch,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async #isHomebrewInstalled(): Promise<boolean> {
|
|
168
|
+
if (!this.#spawnService || !this.#config.homebrew) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const result = await this.#spawnService.spawn(
|
|
172
|
+
["brew", "list", "--versions", this.#config.homebrew.formula],
|
|
173
|
+
{ stdio: "wrapped", longRunning: false },
|
|
174
|
+
);
|
|
175
|
+
return result.ok;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #isWingetInstalled(): Promise<boolean> {
|
|
179
|
+
if (!this.#spawnService || !this.#config.winget) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const result = await this.#spawnService.spawn(
|
|
183
|
+
["winget", "list", "--id", this.#config.winget.packageId],
|
|
184
|
+
{ stdio: "wrapped", longRunning: false },
|
|
185
|
+
);
|
|
186
|
+
return result.ok;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#isLinuxScriptInstall(): boolean {
|
|
190
|
+
return process.execPath.startsWith("/usr/local/bin/");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async #getLatestVersion(installMethod: InstallMethod): Promise<string | undefined> {
|
|
194
|
+
switch (installMethod) {
|
|
195
|
+
case InstallMethod.GITHUB_RELEASE:
|
|
196
|
+
case InstallMethod.LINUX_SCRIPT:
|
|
197
|
+
return this.#getLatestGithubReleaseVersion();
|
|
198
|
+
case InstallMethod.HOMEBREW:
|
|
199
|
+
return this.#getLatestHomebrewVersion();
|
|
200
|
+
case InstallMethod.WINGET:
|
|
201
|
+
return this.#getLatestWingetVersion();
|
|
202
|
+
default:
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async #getLatestGithubReleaseVersion(): Promise<string | undefined> {
|
|
208
|
+
if (!this.#config.githubRelease) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const { owner, repo } = this.#config.githubRelease;
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
const data = (await response.json()) as { tag_name?: string };
|
|
218
|
+
return data.tag_name?.replace(/^v/, "");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
logger.debug(() => `Failed to fetch latest GitHub release for ${owner}/${repo}: ${error}`);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async #getLatestHomebrewVersion(): Promise<string | undefined> {
|
|
226
|
+
if (!this.#config.homebrew) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const { tap, formula } = this.#config.homebrew;
|
|
230
|
+
const [tapOwner, tapName] = tap.split("/");
|
|
231
|
+
if (!tapOwner || !tapName) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const response = await fetch(
|
|
236
|
+
`https://raw.githubusercontent.com/${tapOwner}/homebrew-${tapName}/main/${formula}.rb`,
|
|
237
|
+
);
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const text = await response.text();
|
|
242
|
+
return /version\s+"v?([^"]+)"/.exec(text)?.[1];
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger.debug(() => `Failed to fetch homebrew formula for ${tap}/${formula}: ${error}`);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async #getLatestWingetVersion(): Promise<string | undefined> {
|
|
250
|
+
if (!this.#spawnService || !this.#config.winget) {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
const lines: string[] = [];
|
|
254
|
+
const result = await this.#spawnService.spawn(
|
|
255
|
+
["winget", "show", "--id", this.#config.winget.packageId],
|
|
256
|
+
{
|
|
257
|
+
stdio: "wrapped",
|
|
258
|
+
longRunning: false,
|
|
259
|
+
onOutput: (line) => lines.push(line),
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
if (!result.ok) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
const match = /Version:\s*(\S+)/.exec(line);
|
|
267
|
+
if (match?.[1]) {
|
|
268
|
+
return match[1];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #upgradeViaLinuxScript(): Promise<void> {
|
|
275
|
+
const { scriptUrl } = this.#config.linuxScript!;
|
|
276
|
+
const result = await this.#spawnService!.spawn(["sh", "-c", `curl -fsSL ${scriptUrl} | sh`]);
|
|
277
|
+
if (!result.ok) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Install script failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async #upgradeViaHomebrew(): Promise<void> {
|
|
285
|
+
const { tap, formula } = this.#config.homebrew!;
|
|
286
|
+
const result = await this.#spawnService!.spawn(["brew", "upgrade", `${tap}/${formula}`]);
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`brew upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async #upgradeViaWinget(): Promise<void> {
|
|
295
|
+
const { packageId } = this.#config.winget!;
|
|
296
|
+
const result = await this.#spawnService!.spawn([
|
|
297
|
+
"winget",
|
|
298
|
+
"upgrade",
|
|
299
|
+
"--id",
|
|
300
|
+
packageId,
|
|
301
|
+
"--silent",
|
|
302
|
+
"--accept-package-agreements",
|
|
303
|
+
"--accept-source-agreements",
|
|
304
|
+
]);
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`winget upgrade failed: ${result.error?.message ?? `exit code ${result.exitCode}`}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async #upgradeViaGithubRelease(os: SupportedOs, arch: SupportedArch): Promise<void> {
|
|
313
|
+
const { owner, repo, assetPattern } = this.#config.githubRelease!;
|
|
314
|
+
const archLabel =
|
|
315
|
+
os === SupportedOs.MACOS ? "aarch64" : arch === SupportedArch.X64 ? "x64" : "arm64";
|
|
316
|
+
const assetName = assetPattern.replace("{os}", OS_LABELS[os]).replace("{arch}", archLabel);
|
|
317
|
+
const url = `https://github.com/${owner}/${repo}/releases/latest/download/${assetName}`;
|
|
318
|
+
|
|
319
|
+
const response = await fetch(url);
|
|
320
|
+
if (!response.ok) {
|
|
321
|
+
throw new Error(`Failed to download release asset '${assetName}': HTTP ${response.status}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "upgrade-"));
|
|
325
|
+
const archivePath = join(tmpDir, assetName);
|
|
326
|
+
await Bun.write(archivePath, await response.arrayBuffer());
|
|
327
|
+
|
|
328
|
+
const currentExecutable = process.execPath;
|
|
329
|
+
|
|
330
|
+
if (os === SupportedOs.WINDOWS) {
|
|
331
|
+
const extractResult = await this.#spawnService!.spawn([
|
|
332
|
+
"powershell",
|
|
333
|
+
"-Command",
|
|
334
|
+
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
|
|
335
|
+
]);
|
|
336
|
+
if (!extractResult.ok) {
|
|
337
|
+
throw new Error("Failed to extract release archive");
|
|
338
|
+
}
|
|
339
|
+
const extractedBinary = join(tmpDir, `${this.#cliConfig.name}.exe`);
|
|
340
|
+
const oldPath = `${currentExecutable}.old.exe`;
|
|
341
|
+
const moveResult = await this.#spawnService!.spawn([
|
|
342
|
+
"cmd",
|
|
343
|
+
"/c",
|
|
344
|
+
"move",
|
|
345
|
+
"/y",
|
|
346
|
+
currentExecutable,
|
|
347
|
+
oldPath,
|
|
348
|
+
]);
|
|
349
|
+
if (!moveResult.ok) {
|
|
350
|
+
throw new Error("Failed to move current executable aside");
|
|
351
|
+
}
|
|
352
|
+
const copyResult = await this.#spawnService!.spawn([
|
|
353
|
+
"cmd",
|
|
354
|
+
"/c",
|
|
355
|
+
"copy",
|
|
356
|
+
"/y",
|
|
357
|
+
extractedBinary,
|
|
358
|
+
currentExecutable,
|
|
359
|
+
]);
|
|
360
|
+
if (!copyResult.ok) {
|
|
361
|
+
throw new Error("Failed to copy new executable into place");
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
const extractResult = await this.#spawnService!.spawn([
|
|
365
|
+
"unzip",
|
|
366
|
+
"-o",
|
|
367
|
+
archivePath,
|
|
368
|
+
"-d",
|
|
369
|
+
tmpDir,
|
|
370
|
+
]);
|
|
371
|
+
if (!extractResult.ok) {
|
|
372
|
+
throw new Error("Failed to extract release archive");
|
|
373
|
+
}
|
|
374
|
+
const extractedBinary = join(tmpDir, this.#cliConfig.name);
|
|
375
|
+
await Bun.write(currentExecutable, Bun.file(extractedBinary));
|
|
376
|
+
await this.#spawnService!.spawn(["chmod", "+x", currentExecutable]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SupportedArch, SupportedOs } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Location of a manual GitHub release asset, and how to determine the latest available version.
|
|
5
|
+
*/
|
|
6
|
+
export interface GithubReleaseLocation {
|
|
7
|
+
readonly owner: string;
|
|
8
|
+
readonly repo: string;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Asset filename pattern for a release, e.g. `"example-cli_{os}_{arch}.zip"`.
|
|
12
|
+
* `{os}` is substituted with `Linux`, `MacOS` or `Windows`; `{arch}` with `x64`, `arm64` or
|
|
13
|
+
* `aarch64` (macOS).
|
|
14
|
+
*/
|
|
15
|
+
readonly assetPattern: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Location of a Linux install script, e.g. as documented for `curl -fsSL <scriptUrl> | sh`.
|
|
20
|
+
*/
|
|
21
|
+
export interface LinuxScriptLocation {
|
|
22
|
+
readonly scriptUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Location of a Homebrew tap formula, e.g. `brew install <tap>/<formula>`.
|
|
27
|
+
*/
|
|
28
|
+
export interface HomebrewLocation {
|
|
29
|
+
readonly tap: string;
|
|
30
|
+
readonly formula: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Identifier of a Winget package, e.g. `winget install <packageId>`.
|
|
35
|
+
*/
|
|
36
|
+
export interface WingetLocation {
|
|
37
|
+
readonly packageId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* CLI-author supplied configuration describing which os/arch combinations a CLI supports
|
|
42
|
+
* upgrading, and where to find the latest release for each supported install method.
|
|
43
|
+
*
|
|
44
|
+
* An install method is only usable for an os/arch combination if both its config block is
|
|
45
|
+
* present here AND that combination appears in {@link supportedPlatforms}.
|
|
46
|
+
*/
|
|
47
|
+
export interface UpgradeLocationsConfig {
|
|
48
|
+
readonly supportedPlatforms: ReadonlyArray<{ os: SupportedOs; arch: SupportedArch }>;
|
|
49
|
+
readonly githubRelease?: GithubReleaseLocation;
|
|
50
|
+
readonly linuxScript?: LinuxScriptLocation;
|
|
51
|
+
readonly homebrew?: HomebrewLocation;
|
|
52
|
+
readonly winget?: WingetLocation;
|
|
53
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ServiceInfo, ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
|
+
import type { Context } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
|
+
import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
|
|
4
|
+
import { UPGRADE_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
5
|
+
import { PROMPTER_SERVICE_ID, PromptType } from "@flowscripter/dynamic-cli-framework-api";
|
|
6
|
+
import type { PrompterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
7
|
+
import { KEY_VALUE_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
8
|
+
import type { KeyValueService } from "@flowscripter/dynamic-cli-framework-api";
|
|
9
|
+
import { SPAWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
10
|
+
import type { SpawnService } from "@flowscripter/dynamic-cli-framework-api";
|
|
11
|
+
import { Icon, PRINTER_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
12
|
+
import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
13
|
+
import DefaultUpgradeService from "./DefaultUpgradeService.ts";
|
|
14
|
+
import { UpgradeSubCommand } from "./command/UpgradeSubCommand.ts";
|
|
15
|
+
import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
|
|
16
|
+
import getLogger from "../../util/logger.ts";
|
|
17
|
+
|
|
18
|
+
const logger = getLogger("UpgradeServiceProvider");
|
|
19
|
+
|
|
20
|
+
export default class UpgradeServiceProvider implements ServiceProvider {
|
|
21
|
+
readonly serviceId: string = UPGRADE_SERVICE_ID;
|
|
22
|
+
readonly servicePriority: number;
|
|
23
|
+
readonly #config: UpgradeLocationsConfig;
|
|
24
|
+
#upgradeService: DefaultUpgradeService | undefined;
|
|
25
|
+
#cliConfig: CLIConfig | undefined;
|
|
26
|
+
|
|
27
|
+
public constructor(servicePriority: number, config: UpgradeLocationsConfig) {
|
|
28
|
+
this.servicePriority = servicePriority;
|
|
29
|
+
this.#config = config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public getServiceInfo(cliConfig: CLIConfig): Promise<ServiceInfo> {
|
|
33
|
+
this.#cliConfig = cliConfig;
|
|
34
|
+
this.#upgradeService = new DefaultUpgradeService(this.#config, cliConfig);
|
|
35
|
+
return Promise.resolve({
|
|
36
|
+
service: this.#upgradeService,
|
|
37
|
+
commands: [new UpgradeSubCommand(this.#upgradeService)],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async initService(context: Context): Promise<void> {
|
|
42
|
+
const upgradeService = this.#upgradeService!;
|
|
43
|
+
const cliConfig = this.#cliConfig!;
|
|
44
|
+
|
|
45
|
+
if (context.doesServiceExist(SPAWN_SERVICE_ID)) {
|
|
46
|
+
const spawnService = context.getServiceById(SPAWN_SERVICE_ID) as SpawnService;
|
|
47
|
+
upgradeService.setDependencies(spawnService);
|
|
48
|
+
} else {
|
|
49
|
+
logger.debug(() => "SpawnService not available, upgrade install methods will be unavailable");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
|
|
53
|
+
logger.debug(() => "PrompterService not available, skipping auto-upgrade");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!context.doesServiceExist(KEY_VALUE_SERVICE_ID)) {
|
|
57
|
+
logger.debug(() => "KeyValueService not available, skipping auto-upgrade");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const keyValueService = context.getServiceById(KEY_VALUE_SERVICE_ID) as KeyValueService;
|
|
62
|
+
|
|
63
|
+
if (await keyValueService.hasKey("upgrade-status")) {
|
|
64
|
+
const status = await keyValueService.getKey("upgrade-status");
|
|
65
|
+
if (status === "declined") {
|
|
66
|
+
logger.debug(() => "Auto-upgrade previously declined, skipping");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (status === "enabled") {
|
|
70
|
+
await this.#checkAndUpgrade(context, upgradeService, cliConfig);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const prompterService = context.getServiceById(PROMPTER_SERVICE_ID) as PrompterService;
|
|
76
|
+
if (!prompterService.promptEnabled) {
|
|
77
|
+
logger.debug(() => "Prompting is disabled, skipping auto-upgrade prompt");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const os = upgradeService.detectOs();
|
|
82
|
+
const arch = upgradeService.detectArch();
|
|
83
|
+
if (!os || !arch) {
|
|
84
|
+
logger.debug(() => "Unsupported OS/arch, skipping auto-upgrade prompt");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const installMethod = await upgradeService.detectInstallMethod(os);
|
|
88
|
+
if (!installMethod) {
|
|
89
|
+
logger.debug(() => "No supported install method detected, skipping auto-upgrade prompt");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let enableResult;
|
|
94
|
+
try {
|
|
95
|
+
enableResult = await prompterService.prompt({
|
|
96
|
+
name: "enable-upgrade",
|
|
97
|
+
promptText: "Would you like to enable automatic upgrades on startup?",
|
|
98
|
+
description:
|
|
99
|
+
"This will check for and install newer versions of " +
|
|
100
|
+
`${cliConfig.name} before it runs each time.`,
|
|
101
|
+
type: PromptType.TOGGLE,
|
|
102
|
+
options: [
|
|
103
|
+
{ displayValue: "Yes", returnedValue: true },
|
|
104
|
+
{ displayValue: "No", returnedValue: false },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (enableResult.value !== true) {
|
|
112
|
+
await keyValueService.setKey("upgrade-status", "declined");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await keyValueService.setKey("upgrade-status", "enabled");
|
|
117
|
+
await this.#checkAndUpgrade(context, upgradeService, cliConfig);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #checkAndUpgrade(
|
|
121
|
+
context: Context,
|
|
122
|
+
upgradeService: DefaultUpgradeService,
|
|
123
|
+
cliConfig: CLIConfig,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
const checkResult = await upgradeService.checkForUpgrade();
|
|
127
|
+
if (!checkResult?.updateAvailable) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const upgradeResult = await upgradeService.upgrade();
|
|
131
|
+
if (!context.doesServiceExist(PRINTER_SERVICE_ID)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
|
|
135
|
+
if (upgradeResult.ok) {
|
|
136
|
+
await printerService.info(
|
|
137
|
+
`${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
|
|
138
|
+
Icon.SUCCESS,
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
await printerService.error(
|
|
142
|
+
`Auto-upgrade failed: ${upgradeResult.error?.message ?? "unknown error"}\n`,
|
|
143
|
+
Icon.FAILURE,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.debug(() => `Auto-upgrade check failed: ${error}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SubCommand } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
|
+
import type { Option } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
|
+
import type { Context } from "@flowscripter/dynamic-cli-framework-api";
|
|
4
|
+
import {
|
|
5
|
+
type ArgumentValues,
|
|
6
|
+
ArgumentValueTypeName,
|
|
7
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
8
|
+
import {
|
|
9
|
+
Icon,
|
|
10
|
+
InstallMethod,
|
|
11
|
+
PRINTER_SERVICE_ID,
|
|
12
|
+
SupportedArch,
|
|
13
|
+
SupportedOs,
|
|
14
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
15
|
+
import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
16
|
+
import type DefaultUpgradeService from "../DefaultUpgradeService.ts";
|
|
17
|
+
|
|
18
|
+
export class UpgradeSubCommand implements SubCommand {
|
|
19
|
+
readonly name = "upgrade";
|
|
20
|
+
readonly description = "Upgrade to the latest available version";
|
|
21
|
+
readonly enableConfiguration = false;
|
|
22
|
+
readonly positionals = [];
|
|
23
|
+
|
|
24
|
+
readonly options: ReadonlyArray<Option> = [
|
|
25
|
+
{
|
|
26
|
+
name: "os",
|
|
27
|
+
type: ArgumentValueTypeName.STRING,
|
|
28
|
+
isOptional: true,
|
|
29
|
+
allowableValues: Object.values(SupportedOs),
|
|
30
|
+
description: "Override the detected operating system",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "install-method",
|
|
34
|
+
type: ArgumentValueTypeName.STRING,
|
|
35
|
+
isOptional: true,
|
|
36
|
+
allowableValues: Object.values(InstallMethod),
|
|
37
|
+
description: "Override the detected install method",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
readonly #upgradeService: DefaultUpgradeService;
|
|
42
|
+
|
|
43
|
+
public constructor(upgradeService: DefaultUpgradeService) {
|
|
44
|
+
this.#upgradeService = upgradeService;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public async execute(context: Context, argumentValues: ArgumentValues): Promise<void> {
|
|
48
|
+
const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
|
|
49
|
+
const cliName = context.cliConfig.name;
|
|
50
|
+
|
|
51
|
+
const os = argumentValues.os as SupportedOs | undefined;
|
|
52
|
+
const arch = undefined as SupportedArch | undefined;
|
|
53
|
+
const installMethod = argumentValues["install-method"] as InstallMethod | undefined;
|
|
54
|
+
|
|
55
|
+
const checkResult = await this.#upgradeService.checkForUpgrade(os, arch, installMethod);
|
|
56
|
+
if (!checkResult) {
|
|
57
|
+
await printerService.error(
|
|
58
|
+
`No upgrade location is configured for the detected or requested platform.\n`,
|
|
59
|
+
Icon.FAILURE,
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!checkResult.updateAvailable) {
|
|
65
|
+
await printerService.print(
|
|
66
|
+
`${cliName} is already up to date (${checkResult.currentVersion}).\n`,
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const upgradeResult = await this.#upgradeService.upgrade(os, arch, installMethod);
|
|
72
|
+
if (!upgradeResult.ok) {
|
|
73
|
+
await printerService.error(
|
|
74
|
+
`Failed to upgrade ${cliName}: ${upgradeResult.error?.message ?? "unknown error"}\n`,
|
|
75
|
+
Icon.FAILURE,
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await printerService.print(
|
|
81
|
+
`${cliName} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
|
|
82
|
+
Icon.SUCCESS,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|