@flowscripter/dynamic-cli-framework 7.0.11 → 7.1.1
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 +7 -1
- package/dist/src/cli/BaseCLIFeatureOptions.d.ts +5 -0
- package/dist/src/cli/BaseCLIFeatureOptions.d.ts.map +1 -1
- package/dist/src/cli/DefaultRuntimeCLI.js +1 -1
- package/dist/src/runtime/runner.d.ts +10 -1
- package/dist/src/runtime/runner.d.ts.map +1 -1
- package/dist/src/runtime/runner.js +7 -1
- package/dist/src/service/completion/command/CompletionCompleteSubCommand.d.ts.map +1 -1
- package/dist/src/service/completion/command/CompletionIntegrationSubCommand.d.ts.map +1 -1
- package/dist/src/service/configuration/DefaultKeyValueService.d.ts +9 -1
- package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
- package/dist/src/service/configuration/DefaultKeyValueService.js +16 -1
- package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -1
- package/dist/src/service/configuration/KeyValueServiceProvider.js +13 -6
- package/dist/src/service/prompter/DefaultPrompterService.d.ts +2 -1
- package/dist/src/service/prompter/DefaultPrompterService.d.ts.map +1 -1
- package/dist/src/service/prompter/DefaultPrompterService.js +3 -0
- package/dist/src/service/prompter/PrompterServiceProvider.d.ts +2 -3
- package/dist/src/service/prompter/PrompterServiceProvider.d.ts.map +1 -1
- package/dist/src/service/prompter/PrompterServiceProvider.js +5 -1
- package/dist/src/service/prompter/prompt/PromptContext.d.ts +4 -1
- package/dist/src/service/prompter/prompt/PromptContext.d.ts.map +1 -1
- package/dist/src/service/prompter/prompt/PromptContext.js +10 -0
- package/dist/src/service/prompter/prompt/promptMultiSelect.d.ts.map +1 -1
- package/dist/src/service/prompter/prompt/promptMultiSelect.js +2 -3
- package/dist/src/service/prompter/prompt/promptOpenUrl.d.ts +2 -1
- package/dist/src/service/prompter/prompt/promptOpenUrl.d.ts.map +1 -1
- package/dist/src/service/prompter/prompt/promptOpenUrl.js +14 -10
- package/dist/src/service/prompter/prompt/promptSingleSelect.d.ts.map +1 -1
- package/dist/src/service/prompter/prompt/promptSingleSelect.js +2 -3
- package/dist/src/service/prompter/prompt/promptText.d.ts.map +1 -1
- package/dist/src/service/prompter/prompt/promptText.js +2 -3
- package/dist/src/service/shutdown/DefaultShutdownService.d.ts +3 -0
- package/dist/src/service/shutdown/DefaultShutdownService.d.ts.map +1 -1
- package/dist/src/service/shutdown/DefaultShutdownService.js +7 -0
- package/dist/src/service/shutdown/ShutdownServiceProvider.js +4 -4
- 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 +30 -2
- package/dist/src/service/upgrade/DefaultUpgradeService.d.ts.map +1 -1
- package/dist/src/service/upgrade/DefaultUpgradeService.js +99 -6
- package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts +19 -4
- package/dist/src/service/upgrade/UpgradeServiceProvider.d.ts.map +1 -1
- package/dist/src/service/upgrade/UpgradeServiceProvider.js +51 -12
- package/dist/src/startup/banner/bannerStartupTask.d.ts.map +1 -1
- package/dist/src/startup/banner/bannerStartupTask.js +9 -5
- package/package.json +2 -2
- package/src/cli/BaseCLI.ts +7 -0
- package/src/cli/BaseCLIFeatureOptions.ts +5 -0
- package/src/cli/DefaultRuntimeCLI.ts +1 -1
- package/src/runtime/runner.ts +17 -2
- package/src/service/completion/command/CompletionCompleteSubCommand.ts +2 -4
- package/src/service/completion/command/CompletionIntegrationSubCommand.ts +2 -4
- package/src/service/configuration/DefaultKeyValueService.ts +22 -1
- package/src/service/configuration/KeyValueServiceProvider.ts +18 -6
- package/src/service/prompter/DefaultPrompterService.ts +6 -2
- package/src/service/prompter/PrompterServiceProvider.ts +6 -3
- package/src/service/prompter/prompt/PromptContext.ts +19 -1
- package/src/service/prompter/prompt/promptMultiSelect.ts +2 -3
- package/src/service/prompter/prompt/promptOpenUrl.ts +18 -11
- package/src/service/prompter/prompt/promptSingleSelect.ts +2 -3
- package/src/service/prompter/prompt/promptText.ts +2 -3
- package/src/service/shutdown/DefaultShutdownService.ts +10 -0
- package/src/service/shutdown/ShutdownServiceProvider.ts +4 -4
- package/src/service/spawn/DefaultSpawnService.ts +1 -0
- package/src/service/upgrade/DefaultUpgradeService.ts +112 -5
- package/src/service/upgrade/UpgradeServiceProvider.ts +77 -19
- package/src/service/upgrade/command/UpgradeSubCommand.ts +2 -2
- package/src/startup/banner/bannerStartupTask.ts +11 -5
|
@@ -38,6 +38,10 @@ class UnreachableKeyValueService implements KeyValueService {
|
|
|
38
38
|
return Promise.reject(new Error(UnreachableKeyValueService.#message));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
flush(): Promise<void> {
|
|
42
|
+
return Promise.reject(new Error(UnreachableKeyValueService.#message));
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
static readonly #message =
|
|
42
46
|
"KeyValueService accessed without a resolved scope - this should be unreachable";
|
|
43
47
|
}
|
|
@@ -189,7 +193,9 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
189
193
|
const secretService = this.secretServiceEnabled
|
|
190
194
|
? new DefaultSecretService(this.#cliConfigName!, `${scopeType}_${scopeKey}`)
|
|
191
195
|
: undefined;
|
|
192
|
-
const scopedService = new DefaultKeyValueService(keyValueData, secretService)
|
|
196
|
+
const scopedService = new DefaultKeyValueService(keyValueData, secretService, () =>
|
|
197
|
+
this.#flushDirtyScopes(),
|
|
198
|
+
);
|
|
193
199
|
scopedServices.set(scopeKey, scopedService);
|
|
194
200
|
return scopedService;
|
|
195
201
|
}
|
|
@@ -230,15 +236,21 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
230
236
|
|
|
231
237
|
/**
|
|
232
238
|
* Flush every scoped KeyValueService's data to the configuration file, if any of them report
|
|
233
|
-
* themselves dirty. Registered as a low-priority ShutdownTask (see
|
|
234
|
-
* runs once, after other shutdown cleanup, regardless of how long any
|
|
239
|
+
* themselves dirty, then mark them all clean. Registered as a low-priority ShutdownTask (see
|
|
240
|
+
* {@link initService}) so it runs once, after other shutdown cleanup, regardless of how long any
|
|
241
|
+
* scope was written to. Also backs {@link KeyValueService.flush} for every scope.
|
|
235
242
|
*/
|
|
236
243
|
async #flushDirtyScopes(): Promise<void> {
|
|
237
|
-
const
|
|
244
|
+
const scopedServices = [
|
|
238
245
|
...this.#commandScopedKeyValueServices.values(),
|
|
239
246
|
...this.#serviceScopedKeyValueServices.values(),
|
|
240
|
-
]
|
|
241
|
-
await this.#configurationServiceProvider.flushIfDirty(
|
|
247
|
+
];
|
|
248
|
+
await this.#configurationServiceProvider.flushIfDirty(
|
|
249
|
+
scopedServices.some((service) => service.isDirty()),
|
|
250
|
+
);
|
|
251
|
+
for (const service of scopedServices) {
|
|
252
|
+
service.markClean();
|
|
253
|
+
}
|
|
242
254
|
}
|
|
243
255
|
|
|
244
256
|
public async initService(context: Context): Promise<void> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PrompterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
1
|
+
import type { Context, PrompterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import {
|
|
3
3
|
type Prompt,
|
|
4
4
|
type PromptResult,
|
|
@@ -33,7 +33,7 @@ export const DEFAULT_PROMPTER_CONFIG: PrompterServiceConfig = {
|
|
|
33
33
|
export default class DefaultPrompterService implements PrompterService {
|
|
34
34
|
promptEnabled = true;
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
#ctx: PromptContext;
|
|
37
37
|
|
|
38
38
|
public constructor(
|
|
39
39
|
config: PrompterServiceConfig,
|
|
@@ -44,6 +44,10 @@ export default class DefaultPrompterService implements PrompterService {
|
|
|
44
44
|
this.#ctx = { config, terminal, keyReader, printerService };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
setContext(context: Context): void {
|
|
48
|
+
this.#ctx = { ...this.#ctx, context };
|
|
49
|
+
}
|
|
50
|
+
|
|
47
51
|
prompt(promptDef: Prompt): Promise<PromptResult> {
|
|
48
52
|
if (!this.promptEnabled) {
|
|
49
53
|
if (promptDef.defaultOption) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Context } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import type { ServiceInfo, ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
|
-
import type { PrompterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
4
3
|
import { PROMPTER_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
5
4
|
import NoPromptCommand from "./command/NoPromptCommand.ts";
|
|
6
|
-
import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
|
|
5
|
+
import type { CLIConfig, PrompterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
6
|
+
import DefaultPrompterService from "./DefaultPrompterService.ts";
|
|
7
7
|
|
|
8
8
|
export default class PrompterServiceProvider implements ServiceProvider {
|
|
9
9
|
readonly serviceId: string = PROMPTER_SERVICE_ID;
|
|
@@ -22,7 +22,10 @@ export default class PrompterServiceProvider implements ServiceProvider {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
initService(
|
|
25
|
+
initService(context: Context): Promise<void> {
|
|
26
|
+
if (this.prompterService instanceof DefaultPrompterService) {
|
|
27
|
+
this.prompterService.setContext(context);
|
|
28
|
+
}
|
|
26
29
|
return Promise.resolve();
|
|
27
30
|
}
|
|
28
31
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Context,
|
|
3
|
+
Prompt,
|
|
4
|
+
ShutdownService,
|
|
5
|
+
SpawnService,
|
|
6
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
7
|
+
import { SHUTDOWN_SERVICE_ID, SPAWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
8
|
import type Terminal from "../../../terminal/Terminal.ts";
|
|
3
9
|
import type KeyReader from "../../../terminal/KeyReader.ts";
|
|
4
10
|
import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
@@ -9,6 +15,18 @@ export interface PromptContext {
|
|
|
9
15
|
readonly terminal: Terminal;
|
|
10
16
|
readonly keyReader: KeyReader;
|
|
11
17
|
readonly printerService: PrinterService;
|
|
18
|
+
readonly context?: Context;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function interrupt(ctx: PromptContext): void {
|
|
22
|
+
(ctx.context?.getServiceById(SHUTDOWN_SERVICE_ID) as ShutdownService | undefined)?.interrupt();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getSpawnService(ctx: PromptContext): SpawnService | undefined {
|
|
26
|
+
if (!ctx.context?.doesServiceExist(SPAWN_SERVICE_ID)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return ctx.context.getServiceById(SPAWN_SERVICE_ID) as SpawnService;
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export function physicalLineCount(terminal: Terminal, text: string): number {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Prompt, PromptResult } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import { SpecialKey } from "../../../terminal/KeyReader.ts";
|
|
3
|
-
import
|
|
4
|
-
import { headerLineCount, renderPromptHeader } from "./PromptContext.ts";
|
|
3
|
+
import { headerLineCount, renderPromptHeader, interrupt } from "./PromptContext.ts";
|
|
5
4
|
import type { PromptContext } from "./PromptContext.ts";
|
|
6
5
|
|
|
7
6
|
export default async function promptMultiSelect(
|
|
@@ -74,7 +73,7 @@ export default async function promptMultiSelect(
|
|
|
74
73
|
throw new Error("Prompt cancelled");
|
|
75
74
|
} else if (keyEvent.specialKey === SpecialKey.INTERRUPT) {
|
|
76
75
|
await ctx.terminal.showCursor();
|
|
77
|
-
|
|
76
|
+
interrupt(ctx);
|
|
78
77
|
throw new Error("Interrupted");
|
|
79
78
|
}
|
|
80
79
|
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import type { Prompt, PromptResult } from "@flowscripter/dynamic-cli-framework-api";
|
|
1
|
+
import type { Prompt, PromptResult, SpawnService } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import { SpecialKey } from "../../../terminal/KeyReader.ts";
|
|
3
|
-
import
|
|
4
|
-
import { renderPromptHeader } from "./PromptContext.ts";
|
|
3
|
+
import { getSpawnService, interrupt, renderPromptHeader } from "./PromptContext.ts";
|
|
5
4
|
import type { PromptContext } from "./PromptContext.ts";
|
|
6
5
|
|
|
7
6
|
function isRemoteSession(): boolean {
|
|
8
7
|
return !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
async function defaultOpenUrl(url: string): Promise<void> {
|
|
10
|
+
export async function defaultOpenUrl(spawnService: SpawnService, url: string): Promise<void> {
|
|
12
11
|
const parsed = new URL(url);
|
|
13
12
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14
13
|
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
|
@@ -22,11 +21,16 @@ async function defaultOpenUrl(url: string): Promise<void> {
|
|
|
22
21
|
} else {
|
|
23
22
|
cmd = ["xdg-open", url];
|
|
24
23
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error(`Failed to open URL (exit code ${exitCode})`);
|
|
24
|
+
const result = await spawnService.spawn(cmd, { mode: "ignore", longRunning: false });
|
|
25
|
+
if (result.ok) {
|
|
26
|
+
return;
|
|
29
27
|
}
|
|
28
|
+
if ("error" in result && result.error) {
|
|
29
|
+
throw result.error;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Failed to open URL (exit code ${"exitCode" in result ? result.exitCode : "unknown"})`,
|
|
33
|
+
);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export default async function promptOpenUrl(
|
|
@@ -39,8 +43,11 @@ export default async function promptOpenUrl(
|
|
|
39
43
|
|
|
40
44
|
const url = String(promptDef.options[0]!.returnedValue);
|
|
41
45
|
const displayLabel = promptDef.options[0]!.displayValue;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
46
|
+
const spawnService = getSpawnService(ctx);
|
|
47
|
+
const openUrlFn =
|
|
48
|
+
ctx.config.openUrl ??
|
|
49
|
+
(spawnService ? (target: string) => defaultOpenUrl(spawnService, target) : undefined);
|
|
50
|
+
const canOpenBrowser = openUrlFn !== undefined && !isRemoteSession();
|
|
44
51
|
|
|
45
52
|
ctx.keyReader.enableRawMode();
|
|
46
53
|
try {
|
|
@@ -72,7 +79,7 @@ export default async function promptOpenUrl(
|
|
|
72
79
|
} else if (keyEvent.specialKey === SpecialKey.ESCAPE) {
|
|
73
80
|
throw new Error("Prompt cancelled");
|
|
74
81
|
} else if (keyEvent.specialKey === SpecialKey.INTERRUPT) {
|
|
75
|
-
|
|
82
|
+
interrupt(ctx);
|
|
76
83
|
throw new Error("Interrupted");
|
|
77
84
|
}
|
|
78
85
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Prompt, PromptResult } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import { SpecialKey } from "../../../terminal/KeyReader.ts";
|
|
3
|
-
import
|
|
4
|
-
import { headerLineCount, renderPromptHeader } from "./PromptContext.ts";
|
|
3
|
+
import { headerLineCount, renderPromptHeader, interrupt } from "./PromptContext.ts";
|
|
5
4
|
import type { PromptContext } from "./PromptContext.ts";
|
|
6
5
|
|
|
7
6
|
export default async function promptSingleSelect(
|
|
@@ -75,7 +74,7 @@ export default async function promptSingleSelect(
|
|
|
75
74
|
throw new Error("Prompt cancelled");
|
|
76
75
|
} else if (keyEvent.specialKey === SpecialKey.INTERRUPT) {
|
|
77
76
|
await ctx.terminal.showCursor();
|
|
78
|
-
|
|
77
|
+
interrupt(ctx);
|
|
79
78
|
throw new Error("Interrupted");
|
|
80
79
|
}
|
|
81
80
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Prompt, PromptResult } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
2
|
import type { SingleValueType } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
3
|
import { SpecialKey } from "../../../terminal/KeyReader.ts";
|
|
4
|
-
import
|
|
5
|
-
import { renderPromptHeader } from "./PromptContext.ts";
|
|
4
|
+
import { renderPromptHeader, interrupt } from "./PromptContext.ts";
|
|
6
5
|
import type { PromptContext } from "./PromptContext.ts";
|
|
7
6
|
|
|
8
7
|
export default async function promptText(
|
|
@@ -32,7 +31,7 @@ export default async function promptText(
|
|
|
32
31
|
throw new Error("Prompt cancelled");
|
|
33
32
|
} else if (keyEvent.specialKey === SpecialKey.INTERRUPT) {
|
|
34
33
|
await ctx.terminal.write("\n");
|
|
35
|
-
|
|
34
|
+
interrupt(ctx);
|
|
36
35
|
throw new Error("Interrupted");
|
|
37
36
|
} else if (keyEvent.specialKey === SpecialKey.BACKSPACE) {
|
|
38
37
|
if (buffer.length > 0) {
|
|
@@ -5,6 +5,12 @@ import { shutdownState } from "./ShutdownState.ts";
|
|
|
5
5
|
export default class DefaultShutdownService implements ShutdownService {
|
|
6
6
|
static readonly taskList = new PriorityTaskList<ShutdownTask>();
|
|
7
7
|
|
|
8
|
+
readonly #onInterrupt: () => void;
|
|
9
|
+
|
|
10
|
+
public constructor(onInterrupt: () => void = () => {}) {
|
|
11
|
+
this.#onInterrupt = onInterrupt;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
registerTask(task: ShutdownTask): void {
|
|
9
15
|
DefaultShutdownService.taskList.add(task);
|
|
10
16
|
}
|
|
@@ -17,6 +23,10 @@ export default class DefaultShutdownService implements ShutdownService {
|
|
|
17
23
|
shutdownState.longRunningMode = false;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
interrupt(): void {
|
|
27
|
+
this.#onInterrupt();
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
get isShutdownRequested(): boolean {
|
|
21
31
|
return shutdownState.shutdownRequested;
|
|
22
32
|
}
|
|
@@ -2,7 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import type { ServiceInfo, ServiceProvider } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
3
|
import DefaultShutdownService from "./DefaultShutdownService.ts";
|
|
4
4
|
import type { ShutdownService } from "@flowscripter/dynamic-cli-framework-api";
|
|
5
|
-
import { SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
5
|
+
import { RunState, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
6
6
|
import type { Context } from "@flowscripter/dynamic-cli-framework-api";
|
|
7
7
|
import getLogger from "../../util/logger.ts";
|
|
8
8
|
import type { CLIConfig } from "@flowscripter/dynamic-cli-framework-api";
|
|
@@ -16,7 +16,7 @@ export default class ShutdownServiceProvider implements ServiceProvider {
|
|
|
16
16
|
static #shutdownInProgress = false;
|
|
17
17
|
|
|
18
18
|
public constructor(readonly servicePriority: number) {
|
|
19
|
-
this.#shutdownService = new DefaultShutdownService();
|
|
19
|
+
this.#shutdownService = new DefaultShutdownService(ShutdownServiceProvider.onInterrupt);
|
|
20
20
|
process.on("beforeExit", ShutdownServiceProvider.shutdown);
|
|
21
21
|
process.on("SIGINT", ShutdownServiceProvider.onInterrupt);
|
|
22
22
|
process.on("SIGTERM", ShutdownServiceProvider.onTerminate);
|
|
@@ -37,7 +37,7 @@ export default class ShutdownServiceProvider implements ServiceProvider {
|
|
|
37
37
|
shutdownState.interruptCount++;
|
|
38
38
|
if (!shutdownState.longRunningMode || shutdownState.interruptCount >= 3) {
|
|
39
39
|
ShutdownServiceProvider.shutdown().then(() => {
|
|
40
|
-
process.exit(
|
|
40
|
+
process.exit(RunState.INTERRUPTED);
|
|
41
41
|
});
|
|
42
42
|
} else {
|
|
43
43
|
shutdownState.shutdownRequested = true;
|
|
@@ -46,7 +46,7 @@ export default class ShutdownServiceProvider implements ServiceProvider {
|
|
|
46
46
|
|
|
47
47
|
static onTerminate(): void {
|
|
48
48
|
ShutdownServiceProvider.shutdown().then(() => {
|
|
49
|
-
process.exit(
|
|
49
|
+
process.exit(RunState.TERMINATED);
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -108,6 +108,7 @@ export default class DefaultSpawnService implements SpawnService {
|
|
|
108
108
|
try {
|
|
109
109
|
proc = Bun.spawn(resolveForPlatform(command), {
|
|
110
110
|
cwd: options.cwd,
|
|
111
|
+
env: options.env === undefined ? undefined : { ...process.env, ...options.env },
|
|
111
112
|
stdin: mode === "inherit" ? "inherit" : "ignore",
|
|
112
113
|
stdout: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
|
|
113
114
|
stderr: mode === "wrapped" ? "pipe" : mode === "ignore" ? "ignore" : "inherit",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { mkdtemp, rename, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import type {
|
|
7
7
|
CLIConfig,
|
|
8
8
|
Context,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
InstallMethod,
|
|
22
22
|
KEY_VALUE_SERVICE_ID,
|
|
23
23
|
PRINTER_SERVICE_ID,
|
|
24
|
+
RunState,
|
|
24
25
|
SPAWN_SERVICE_ID,
|
|
25
26
|
SupportedArch,
|
|
26
27
|
SupportedOs,
|
|
@@ -85,6 +86,46 @@ export function describeUpgradeCheckResult(result: UpgradeCheckResult): string {
|
|
|
85
86
|
: `Upgrade check result: ${JSON.stringify(result)}`;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// Environment variable set on a process restarted after an automatic upgrade, holding the version
|
|
90
|
+
// which restarted it (see DefaultUpgradeService.restart() and restartedFromVersion).
|
|
91
|
+
const RESTARTED_FROM_VERSION_ENV_VAR = "DYNAMIC_CLI_FRAMEWORK_REEXEC_FROM";
|
|
92
|
+
|
|
93
|
+
// Exit codes from 129 upwards are reported for a child terminated by a signal (128 + signal number).
|
|
94
|
+
const SIGNAL_EXIT_CODE_BASE = 128;
|
|
95
|
+
|
|
96
|
+
const RUN_STATE_VALUES = new Set<number>(
|
|
97
|
+
Object.values(RunState).filter((value): value is number => typeof value === "number"),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map the result of spawning a restarted CLI to the {@link RunState} it ended with.
|
|
102
|
+
*
|
|
103
|
+
* The child is the same framework CLI, so it normally exits with a {@link RunState} value, which is
|
|
104
|
+
* returned as is (this includes 130 for SIGINT and 143 for SIGTERM). Termination by any other
|
|
105
|
+
* signal, or a timeout, maps to {@link RunState.TERMINATED}, and any other exit code to
|
|
106
|
+
* {@link RunState.RUNTIME_ERROR}.
|
|
107
|
+
*
|
|
108
|
+
* @return the {@link RunState}, or `undefined` if the child could not be started.
|
|
109
|
+
*/
|
|
110
|
+
export function toRunState(result: SpawnResult): RunState | undefined {
|
|
111
|
+
if (result.ok) {
|
|
112
|
+
return RunState.SUCCESS;
|
|
113
|
+
}
|
|
114
|
+
if ("timedOut" in result) {
|
|
115
|
+
return RunState.TERMINATED;
|
|
116
|
+
}
|
|
117
|
+
if (result.exitCode === undefined) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
if (RUN_STATE_VALUES.has(result.exitCode)) {
|
|
121
|
+
return result.exitCode as RunState;
|
|
122
|
+
}
|
|
123
|
+
if (result.exitCode > SIGNAL_EXIT_CODE_BASE) {
|
|
124
|
+
return RunState.TERMINATED;
|
|
125
|
+
}
|
|
126
|
+
return RunState.RUNTIME_ERROR;
|
|
127
|
+
}
|
|
128
|
+
|
|
88
129
|
const OS_LABELS: Record<SupportedOs, string> = {
|
|
89
130
|
[SupportedOs.LINUX]: "Linux",
|
|
90
131
|
[SupportedOs.MACOS]: "MacOS",
|
|
@@ -96,10 +137,12 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
96
137
|
#upgradeCheckPromise: Promise<UpgradeCheckResult> | undefined;
|
|
97
138
|
readonly #config: UpgradeLocationsConfig;
|
|
98
139
|
readonly #cliConfig: CLIConfig;
|
|
140
|
+
public readonly restartedFromVersion: string | undefined;
|
|
99
141
|
|
|
100
142
|
public constructor(config: UpgradeLocationsConfig, cliConfig: CLIConfig) {
|
|
101
143
|
this.#config = config;
|
|
102
144
|
this.#cliConfig = cliConfig;
|
|
145
|
+
this.restartedFromVersion = process.env[RESTARTED_FROM_VERSION_ENV_VAR] || undefined;
|
|
103
146
|
}
|
|
104
147
|
|
|
105
148
|
public setContext(context: Context): void {
|
|
@@ -214,6 +257,67 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
214
257
|
return cached as unknown as UpgradeCheckResult | undefined;
|
|
215
258
|
}
|
|
216
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Resolve the executable to restart into after a successful upgrade via `installMethod`.
|
|
262
|
+
*
|
|
263
|
+
* @return the executable path, or `undefined` if it cannot be resolved (e.g. for winget, or a
|
|
264
|
+
* homebrew install whose `opt` link is missing).
|
|
265
|
+
*/
|
|
266
|
+
public resolveUpgradedExecutable(installMethod: InstallMethod): string | undefined {
|
|
267
|
+
switch (installMethod) {
|
|
268
|
+
case InstallMethod.GITHUB_RELEASE:
|
|
269
|
+
case InstallMethod.LINUX_SCRIPT:
|
|
270
|
+
return process.execPath;
|
|
271
|
+
case InstallMethod.HOMEBREW:
|
|
272
|
+
return this.#resolveHomebrewOptExecutable();
|
|
273
|
+
case InstallMethod.WINGET:
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
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
|
+
/**
|
|
297
|
+
* Run `executable` with `args` in place of this process, with terminal input and output
|
|
298
|
+
* inherited, and wait for it to exit. The child process sees the current version as its
|
|
299
|
+
* {@link restartedFromVersion}.
|
|
300
|
+
*
|
|
301
|
+
* @return the {@link RunState} the child ended with (see {@link toRunState}), or `undefined` if
|
|
302
|
+
* it could not be started.
|
|
303
|
+
*/
|
|
304
|
+
public async restart(
|
|
305
|
+
executable: string,
|
|
306
|
+
args: ReadonlyArray<string>,
|
|
307
|
+
): Promise<RunState | undefined> {
|
|
308
|
+
const spawnService = this.#spawnService;
|
|
309
|
+
if (!spawnService) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
const result = await spawnService.spawn([executable, ...args], {
|
|
313
|
+
mode: "inherit",
|
|
314
|
+
// Ctrl-C reaches the child directly from the terminal, so this process only notes it
|
|
315
|
+
longRunning: true,
|
|
316
|
+
env: { [RESTARTED_FROM_VERSION_ENV_VAR]: this.#cliConfig.version },
|
|
317
|
+
});
|
|
318
|
+
return toRunState(result);
|
|
319
|
+
}
|
|
320
|
+
|
|
217
321
|
public detectOs(): SupportedOs | undefined {
|
|
218
322
|
switch (process.platform) {
|
|
219
323
|
case "linux":
|
|
@@ -430,14 +534,17 @@ export default class DefaultUpgradeService implements UpgradeService {
|
|
|
430
534
|
// `brew`, which has a slow cold start - avoiding it keeps the background upgrade-check
|
|
431
535
|
// StartupTask (see UpgradeServiceProvider) fast even though it runs to completion.
|
|
432
536
|
#isRunningFromHomebrewCellar(formula: string): boolean {
|
|
433
|
-
|
|
537
|
+
return this.#resolveRealExecutable().includes(`/Cellar/${formula}/`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#resolveRealExecutable(): string {
|
|
434
541
|
try {
|
|
435
|
-
|
|
542
|
+
return realpathSync(process.execPath);
|
|
436
543
|
} catch {
|
|
437
544
|
// process.execPath may not resolve on disk (e.g. a fabricated path in tests) - fall back to
|
|
438
545
|
// the unresolved path rather than treating that as "not installed".
|
|
546
|
+
return process.execPath;
|
|
439
547
|
}
|
|
440
|
-
return realExecutable.includes(`/Cellar/${formula}/`);
|
|
441
548
|
}
|
|
442
549
|
|
|
443
550
|
async #isHomebrewInstalled(): Promise<boolean> {
|
|
@@ -16,7 +16,7 @@ import DefaultUpgradeService from "./DefaultUpgradeService.ts";
|
|
|
16
16
|
import { UpgradeSubCommand } from "./command/UpgradeSubCommand.ts";
|
|
17
17
|
import type { UpgradeLocationsConfig } from "./UpgradeLocationsConfig.ts";
|
|
18
18
|
import getLogger from "../../util/logger.ts";
|
|
19
|
-
import type { StartupTask } from "@flowscripter/dynamic-cli-framework-api";
|
|
19
|
+
import type { StartupTask, StartupTaskOutcome } from "@flowscripter/dynamic-cli-framework-api";
|
|
20
20
|
|
|
21
21
|
const logger = getLogger("UpgradeServiceProvider");
|
|
22
22
|
|
|
@@ -42,7 +42,7 @@ export function createUpgradeCheckStartupTask(
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Build the blocking {@link StartupTask} which offers to enable automatic upgrades and, when they
|
|
45
|
-
* are enabled, checks for and installs a newer version (see
|
|
45
|
+
* are enabled, checks for and installs a newer version, optionally restarting into it (see
|
|
46
46
|
* {@link UpgradeServiceProvider.runAutoUpgrade}). It is registered separately from the provider's
|
|
47
47
|
* own init task so it can run at a lower priority, after consumer startup tasks such as the banner.
|
|
48
48
|
*/
|
|
@@ -65,10 +65,31 @@ export default class UpgradeServiceProvider implements ServiceProvider {
|
|
|
65
65
|
#upgradeService: DefaultUpgradeService | undefined;
|
|
66
66
|
#cliConfig: CLIConfig | undefined;
|
|
67
67
|
#keyValueService: KeyValueService | undefined;
|
|
68
|
+
readonly #restartAfterAutoUpgrade: boolean;
|
|
69
|
+
#restartArgs: ReadonlyArray<string> = [];
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
/**
|
|
72
|
+
* @param servicePriority the priority of the service.
|
|
73
|
+
* @param config the upgrade locations.
|
|
74
|
+
* @param restartAfterAutoUpgrade whether to run the upgraded executable, with the arguments from
|
|
75
|
+
* {@link setRestartArgs}, after a successful automatic upgrade and end the run with its result.
|
|
76
|
+
*/
|
|
77
|
+
public constructor(
|
|
78
|
+
servicePriority: number,
|
|
79
|
+
config: UpgradeLocationsConfig,
|
|
80
|
+
restartAfterAutoUpgrade = false,
|
|
81
|
+
) {
|
|
70
82
|
this.servicePriority = servicePriority;
|
|
71
83
|
this.#config = config;
|
|
84
|
+
this.#restartAfterAutoUpgrade = restartAfterAutoUpgrade;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Set the CLI arguments to run the upgraded executable with when restarting after an automatic
|
|
89
|
+
* upgrade. These are kept internal to the framework and never exposed to startup tasks.
|
|
90
|
+
*/
|
|
91
|
+
public setRestartArgs(args: ReadonlyArray<string>): void {
|
|
92
|
+
this.#restartArgs = args;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
public get upgradeService(): DefaultUpgradeService | undefined {
|
|
@@ -113,13 +134,25 @@ export default class UpgradeServiceProvider implements ServiceProvider {
|
|
|
113
134
|
* service's scope) and, when they are enabled, check for and install a newer version. Must be
|
|
114
135
|
* called after {@link initService}.
|
|
115
136
|
*
|
|
137
|
+
* After a successful upgrade, when restarting is enabled, the upgraded executable is run with the
|
|
138
|
+
* arguments from {@link setRestartArgs} and its result is returned as an `exitRequest`. Does
|
|
139
|
+
* nothing in a process which was itself started by such a restart.
|
|
140
|
+
*
|
|
116
141
|
* @param context the startup task's context, used to look up the Prompter and Printer services.
|
|
117
142
|
*/
|
|
118
|
-
public async runAutoUpgrade(context: Context): Promise<void> {
|
|
143
|
+
public async runAutoUpgrade(context: Context): Promise<void | StartupTaskOutcome> {
|
|
119
144
|
const upgradeService = this.#upgradeService!;
|
|
120
145
|
const cliConfig = this.#cliConfig!;
|
|
121
146
|
const keyValueService = this.#keyValueService;
|
|
122
147
|
|
|
148
|
+
if (upgradeService.restartedFromVersion !== undefined) {
|
|
149
|
+
logger.debug(
|
|
150
|
+
() =>
|
|
151
|
+
`Restarted from version ${upgradeService.restartedFromVersion}, skipping auto-upgrade`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
|
|
124
157
|
logger.debug(() => "PrompterService not available, skipping auto-upgrade");
|
|
125
158
|
return;
|
|
@@ -136,8 +169,7 @@ export default class UpgradeServiceProvider implements ServiceProvider {
|
|
|
136
169
|
return;
|
|
137
170
|
}
|
|
138
171
|
if (status === "enabled") {
|
|
139
|
-
|
|
140
|
-
return;
|
|
172
|
+
return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
|
|
141
173
|
}
|
|
142
174
|
}
|
|
143
175
|
|
|
@@ -183,14 +215,15 @@ export default class UpgradeServiceProvider implements ServiceProvider {
|
|
|
183
215
|
}
|
|
184
216
|
|
|
185
217
|
await keyValueService.set("upgrade-status", "enabled");
|
|
186
|
-
|
|
218
|
+
return this.#checkAndUpgrade(context, upgradeService, cliConfig, keyValueService);
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
async #checkAndUpgrade(
|
|
190
222
|
context: Context,
|
|
191
223
|
upgradeService: DefaultUpgradeService,
|
|
192
224
|
cliConfig: CLIConfig,
|
|
193
|
-
|
|
225
|
+
keyValueService: KeyValueService,
|
|
226
|
+
): Promise<void | StartupTaskOutcome> {
|
|
194
227
|
try {
|
|
195
228
|
const checkResult = await upgradeService.getUpgradeCheckResult();
|
|
196
229
|
if (checkResult.status === "failed") {
|
|
@@ -201,21 +234,46 @@ export default class UpgradeServiceProvider implements ServiceProvider {
|
|
|
201
234
|
return;
|
|
202
235
|
}
|
|
203
236
|
const upgradeResult = await upgradeService.upgrade();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
await printerService.info(
|
|
210
|
-
`${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
|
|
211
|
-
Icon.SUCCESS,
|
|
212
|
-
);
|
|
213
|
-
} else {
|
|
214
|
-
await printerService.error(
|
|
237
|
+
const printerService = context.doesServiceExist(PRINTER_SERVICE_ID)
|
|
238
|
+
? (context.getServiceById(PRINTER_SERVICE_ID) as PrinterService)
|
|
239
|
+
: undefined;
|
|
240
|
+
if (!upgradeResult.ok) {
|
|
241
|
+
await printerService?.error(
|
|
215
242
|
`Auto-upgrade failed: ${upgradeResult.error?.message ?? "unknown error"}\n`,
|
|
216
243
|
Icon.FAILURE,
|
|
217
244
|
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
await printerService?.info(
|
|
248
|
+
`${cliConfig.name} upgraded (${upgradeResult.oldVersion} -> ${upgradeResult.newVersion})\n`,
|
|
249
|
+
Icon.SUCCESS,
|
|
250
|
+
);
|
|
251
|
+
if (!this.#restartAfterAutoUpgrade) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const executable = upgradeService.resolveUpgradedExecutable(checkResult.installMethod);
|
|
256
|
+
if (executable === undefined) {
|
|
257
|
+
await printerService?.info(
|
|
258
|
+
`Restart ${cliConfig.name} to use ${upgradeResult.newVersion}\n`,
|
|
259
|
+
Icon.INFORMATION,
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// the cache write from the background upgrade check lands before the flush, so the shutdown
|
|
265
|
+
// flush has nothing left to write over the restarted process's configuration
|
|
266
|
+
await upgradeService.refreshUpgradeCheckCache();
|
|
267
|
+
// written now so the restarted process sees the stored upgrade-status
|
|
268
|
+
await keyValueService.flush();
|
|
269
|
+
const runState = await upgradeService.restart(executable, this.#restartArgs);
|
|
270
|
+
if (runState !== undefined) {
|
|
271
|
+
return { exitRequest: { runState } };
|
|
218
272
|
}
|
|
273
|
+
await printerService?.warn(
|
|
274
|
+
`${cliConfig.name} upgraded to ${upgradeResult.newVersion}; restart it to use the new version\n`,
|
|
275
|
+
Icon.ALERT,
|
|
276
|
+
);
|
|
219
277
|
} catch (error) {
|
|
220
278
|
logger.debug(() => `Auto-upgrade check failed: ${error}`);
|
|
221
279
|
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
UPGRADE_SERVICE_ID,
|
|
12
12
|
} from "@flowscripter/dynamic-cli-framework-api";
|
|
13
13
|
import type { PrinterService } from "@flowscripter/dynamic-cli-framework-api";
|
|
14
|
-
import type
|
|
14
|
+
import type { UpgradeService } from "@flowscripter/dynamic-cli-framework-api";
|
|
15
15
|
|
|
16
16
|
export class UpgradeSubCommand implements SubCommand {
|
|
17
17
|
readonly name = "upgrade";
|
|
@@ -38,7 +38,7 @@ export class UpgradeSubCommand implements SubCommand {
|
|
|
38
38
|
|
|
39
39
|
public async execute(context: Context, argumentValues: Values): Promise<void> {
|
|
40
40
|
const printerService = context.getServiceById(PRINTER_SERVICE_ID) as PrinterService;
|
|
41
|
-
const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as
|
|
41
|
+
const upgradeService = context.getServiceById(UPGRADE_SERVICE_ID) as UpgradeService;
|
|
42
42
|
const cliName = context.cliConfig.name;
|
|
43
43
|
const currentVersion = context.cliConfig.version;
|
|
44
44
|
|