@kzheart_/mc-pilot 0.9.0 → 0.9.2
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/data/variants.json +24 -21
- package/dist/commands/client.js +22 -73
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +211 -0
- package/dist/download/VersionMatrix.d.ts +1 -15
- package/dist/download/VersionMatrix.js +23 -19
- package/dist/download/client/ClientDownloader.d.ts +2 -2
- package/dist/download/client/ClientDownloader.js +36 -8
- package/dist/download/client/FabricRuntimeDownloader.d.ts +2 -0
- package/dist/download/client/FabricRuntimeDownloader.js +47 -17
- package/dist/index.js +2 -0
- package/dist/instance/ClientInstanceManager.d.ts +3 -0
- package/dist/instance/ClientInstanceManager.js +108 -98
- package/dist/instance/ServerInstanceManager.d.ts +5 -0
- package/dist/instance/ServerInstanceManager.js +85 -3
- package/dist/util/command-log.d.ts +12 -0
- package/dist/util/command-log.js +13 -0
- package/dist/util/global-state.d.ts +4 -0
- package/dist/util/global-state.js +22 -0
- package/dist/util/instance-types.d.ts +1 -0
- package/dist/util/net.d.ts +1 -0
- package/dist/util/net.js +14 -12
- package/dist/util/state.d.ts +5 -0
- package/dist/util/state.js +81 -2
- package/package.json +3 -1
- package/scripts/launch-fabric-client.mjs +50 -8
|
@@ -7,7 +7,7 @@ import { CacheManager } from "../CacheManager.js";
|
|
|
7
7
|
import { copyFileIfMissing, downloadFile } from "../DownloadUtils.js";
|
|
8
8
|
import { detectJava } from "../JavaDetector.js";
|
|
9
9
|
import { findVariantByVersionAndLoader, getDefaultVariant, getModArtifactFileName, loadModVariantCatalog } from "../ModVariantCatalog.js";
|
|
10
|
-
import {
|
|
10
|
+
import { prepareManagedClientRuntime } from "./FabricRuntimeDownloader.js";
|
|
11
11
|
function getLaunchScriptPath() {
|
|
12
12
|
const thisFile = fileURLToPath(import.meta.url);
|
|
13
13
|
// dist/download/client/ClientDownloader.js -> scripts/launch-fabric-client.mjs
|
|
@@ -15,13 +15,27 @@ function getLaunchScriptPath() {
|
|
|
15
15
|
}
|
|
16
16
|
const GITHUB_RELEASE_BASE_URL = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
|
|
17
17
|
function ensureSupportedVariant(variant) {
|
|
18
|
-
if (variant.
|
|
18
|
+
if (variant.support !== "ready" && variant.support !== "configured") {
|
|
19
19
|
throw new MctError({
|
|
20
|
-
code: "
|
|
21
|
-
message: `
|
|
20
|
+
code: "VARIANT_NOT_BUILDABLE",
|
|
21
|
+
message: `Variant ${variant.id} is not buildable yet`,
|
|
22
|
+
details: {
|
|
23
|
+
support: variant.support,
|
|
24
|
+
validation: variant.validation
|
|
25
|
+
}
|
|
26
|
+
}, 4);
|
|
27
|
+
}
|
|
28
|
+
if (variant.loader === "fabric" && (!variant.fabricLoaderVersion || !variant.yarnMappings)) {
|
|
29
|
+
throw new MctError({
|
|
30
|
+
code: "VARIANT_NOT_BUILDABLE",
|
|
31
|
+
message: `Variant ${variant.id} is not buildable yet`,
|
|
32
|
+
details: {
|
|
33
|
+
support: variant.support,
|
|
34
|
+
validation: variant.validation
|
|
35
|
+
}
|
|
22
36
|
}, 4);
|
|
23
37
|
}
|
|
24
|
-
if (
|
|
38
|
+
if (variant.loader === "forge" && !variant.forgeVersion) {
|
|
25
39
|
throw new MctError({
|
|
26
40
|
code: "VARIANT_NOT_BUILDABLE",
|
|
27
41
|
message: `Variant ${variant.id} is not buildable yet`,
|
|
@@ -31,6 +45,12 @@ function ensureSupportedVariant(variant) {
|
|
|
31
45
|
}
|
|
32
46
|
}, 4);
|
|
33
47
|
}
|
|
48
|
+
if (variant.loader !== "fabric" && variant.loader !== "forge") {
|
|
49
|
+
throw new MctError({
|
|
50
|
+
code: "UNSUPPORTED_LOADER",
|
|
51
|
+
message: `Loader ${variant.loader} is not implemented yet`
|
|
52
|
+
}, 4);
|
|
53
|
+
}
|
|
34
54
|
}
|
|
35
55
|
export async function resolveArtifact(cwd, variant, cacheManager, fetchImpl = fetch) {
|
|
36
56
|
const artifactFileName = getModArtifactFileName(variant);
|
|
@@ -51,7 +71,7 @@ export async function resolveArtifact(cwd, variant, cacheManager, fetchImpl = fe
|
|
|
51
71
|
}
|
|
52
72
|
catch { }
|
|
53
73
|
// 3. Download from GitHub Releases
|
|
54
|
-
const modVersion = variant.modVersion ?? "0.1
|
|
74
|
+
const modVersion = variant.modVersion ?? "0.9.1";
|
|
55
75
|
const releaseTag = `v${modVersion}`;
|
|
56
76
|
const downloadUrl = `${GITHUB_RELEASE_BASE_URL}/${releaseTag}/${artifactFileName}`;
|
|
57
77
|
try {
|
|
@@ -114,6 +134,12 @@ function resolveLaunchRuntimePaths(cwd, options) {
|
|
|
114
134
|
};
|
|
115
135
|
}
|
|
116
136
|
function buildLaunchArgs(runtimePaths, variant) {
|
|
137
|
+
if (variant.loader !== "fabric") {
|
|
138
|
+
throw new MctError({
|
|
139
|
+
code: "INVALID_PARAMS",
|
|
140
|
+
message: `Custom runtime directories are not supported for ${variant.loader} clients yet`
|
|
141
|
+
}, 4);
|
|
142
|
+
}
|
|
117
143
|
return [
|
|
118
144
|
"--instance-dir",
|
|
119
145
|
runtimePaths.instanceDir,
|
|
@@ -169,11 +195,13 @@ export async function downloadClientModToDir(cwd, targetDir, options, dependenci
|
|
|
169
195
|
const cacheManager = dependencies.cacheManager ?? new CacheManager();
|
|
170
196
|
const detectJavaImpl = dependencies.detectJavaImpl ?? detectJava;
|
|
171
197
|
const fetchImpl = dependencies.fetchImpl ?? fetch;
|
|
172
|
-
const prepareManagedRuntimeImpl = dependencies.prepareManagedRuntimeImpl ??
|
|
198
|
+
const prepareManagedRuntimeImpl = dependencies.prepareManagedRuntimeImpl ?? prepareManagedClientRuntime;
|
|
173
199
|
const catalog = await loadModVariantCatalog();
|
|
174
200
|
const variant = options.version
|
|
175
201
|
? findVariantByVersionAndLoader(catalog, options.version, loader)
|
|
176
|
-
:
|
|
202
|
+
: loader === "fabric"
|
|
203
|
+
? getDefaultVariant(catalog)
|
|
204
|
+
: undefined;
|
|
177
205
|
if (!variant) {
|
|
178
206
|
throw new MctError({
|
|
179
207
|
code: "VARIANT_NOT_FOUND",
|
|
@@ -12,3 +12,5 @@ export interface PrepareFabricRuntimeOptions {
|
|
|
12
12
|
gameDir: string;
|
|
13
13
|
}
|
|
14
14
|
export declare function prepareManagedFabricRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
|
|
15
|
+
export declare function prepareManagedForgeRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
|
|
16
|
+
export declare function prepareManagedClientRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
|
|
@@ -2,7 +2,7 @@ import { access, mkdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
import { MinecraftFolder, Version } from "@xmcl/core";
|
|
5
|
-
import { getVersionList, installDependencies, installFabric, installVersion } from "@xmcl/installer";
|
|
5
|
+
import { getVersionList, installDependencies, installFabric, installForge, installVersion } from "@xmcl/installer";
|
|
6
6
|
import { Agent, interceptors } from "undici";
|
|
7
7
|
import { MctError } from "../../util/errors.js";
|
|
8
8
|
import { applyArm64LwjglPatch } from "./Arm64LwjglPatcher.js";
|
|
@@ -42,19 +42,11 @@ async function fetchWithRetry(input, init, attempts = 4) {
|
|
|
42
42
|
}
|
|
43
43
|
throw lastError;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
async function prepareManagedRuntime(variant, runtimeOptions, dependencies, expectedVersionId, installLoader) {
|
|
46
46
|
const fetchImpl = dependencies.fetchImpl ?? fetchWithRetry;
|
|
47
|
-
const loaderVersion = variant.fabricLoaderVersion;
|
|
48
|
-
if (!loaderVersion) {
|
|
49
|
-
throw new MctError({
|
|
50
|
-
code: "VARIANT_NOT_BUILDABLE",
|
|
51
|
-
message: `Variant ${variant.id} does not define a Fabric loader version`
|
|
52
|
-
}, 4);
|
|
53
|
-
}
|
|
54
47
|
const { runtimeRootDir, gameDir } = runtimeOptions;
|
|
55
48
|
await mkdir(runtimeRootDir, { recursive: true });
|
|
56
49
|
await mkdir(gameDir, { recursive: true });
|
|
57
|
-
const expectedVersionId = `${variant.minecraftVersion}-fabric${loaderVersion}`;
|
|
58
50
|
const readyMarker = path.join(runtimeRootDir, `.ready-${expectedVersionId}`);
|
|
59
51
|
try {
|
|
60
52
|
await access(readyMarker);
|
|
@@ -77,13 +69,7 @@ export async function prepareManagedFabricRuntime(variant, runtimeOptions, depen
|
|
|
77
69
|
side: "client",
|
|
78
70
|
dispatcher: DOWNLOAD_DISPATCHER
|
|
79
71
|
});
|
|
80
|
-
const installedVersionId = await
|
|
81
|
-
minecraftVersion: variant.minecraftVersion,
|
|
82
|
-
version: loaderVersion,
|
|
83
|
-
minecraft,
|
|
84
|
-
side: "client",
|
|
85
|
-
fetch: fetchImpl
|
|
86
|
-
});
|
|
72
|
+
const installedVersionId = await installLoader(minecraft, fetchImpl);
|
|
87
73
|
const resolvedVersion = await Version.parse(minecraft, installedVersionId);
|
|
88
74
|
await installDependencies(resolvedVersion, {
|
|
89
75
|
side: "client",
|
|
@@ -99,3 +85,47 @@ export async function prepareManagedFabricRuntime(variant, runtimeOptions, depen
|
|
|
99
85
|
versionId: installedVersionId
|
|
100
86
|
};
|
|
101
87
|
}
|
|
88
|
+
export async function prepareManagedFabricRuntime(variant, runtimeOptions, dependencies = {}) {
|
|
89
|
+
const loaderVersion = variant.fabricLoaderVersion;
|
|
90
|
+
if (!loaderVersion) {
|
|
91
|
+
throw new MctError({
|
|
92
|
+
code: "VARIANT_NOT_BUILDABLE",
|
|
93
|
+
message: `Variant ${variant.id} does not define a Fabric loader version`
|
|
94
|
+
}, 4);
|
|
95
|
+
}
|
|
96
|
+
return prepareManagedRuntime(variant, runtimeOptions, dependencies, `${variant.minecraftVersion}-fabric${loaderVersion}`, (minecraft, fetchImpl) => installFabric({
|
|
97
|
+
minecraftVersion: variant.minecraftVersion,
|
|
98
|
+
version: loaderVersion,
|
|
99
|
+
minecraft,
|
|
100
|
+
side: "client",
|
|
101
|
+
fetch: fetchImpl
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
export async function prepareManagedForgeRuntime(variant, runtimeOptions, dependencies = {}) {
|
|
105
|
+
const forgeVersion = variant.forgeVersion;
|
|
106
|
+
if (!forgeVersion) {
|
|
107
|
+
throw new MctError({
|
|
108
|
+
code: "VARIANT_NOT_BUILDABLE",
|
|
109
|
+
message: `Variant ${variant.id} does not define a Forge version`
|
|
110
|
+
}, 4);
|
|
111
|
+
}
|
|
112
|
+
return prepareManagedRuntime(variant, runtimeOptions, dependencies, `${variant.minecraftVersion}-forge-${forgeVersion}`, (minecraft) => installForge({
|
|
113
|
+
mcversion: variant.minecraftVersion,
|
|
114
|
+
version: forgeVersion
|
|
115
|
+
}, minecraft, {
|
|
116
|
+
side: "client",
|
|
117
|
+
dispatcher: DOWNLOAD_DISPATCHER
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
export async function prepareManagedClientRuntime(variant, runtimeOptions, dependencies = {}) {
|
|
121
|
+
if (variant.loader === "fabric") {
|
|
122
|
+
return prepareManagedFabricRuntime(variant, runtimeOptions, dependencies);
|
|
123
|
+
}
|
|
124
|
+
if (variant.loader === "forge") {
|
|
125
|
+
return prepareManagedForgeRuntime(variant, runtimeOptions, dependencies);
|
|
126
|
+
}
|
|
127
|
+
throw new MctError({
|
|
128
|
+
code: "UNSUPPORTED_LOADER",
|
|
129
|
+
message: `Loader ${variant.loader} is not implemented yet`
|
|
130
|
+
}, 4);
|
|
131
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createEntityCommand } from "./commands/entity.js";
|
|
|
12
12
|
import { createEventsCommand } from "./commands/events.js";
|
|
13
13
|
import { createGuiCommand } from "./commands/gui.js";
|
|
14
14
|
import { createHudCommand } from "./commands/hud.js";
|
|
15
|
+
import { createImageCommand } from "./commands/image.js";
|
|
15
16
|
import { createInputCommand } from "./commands/input.js";
|
|
16
17
|
import { createInventoryCommand } from "./commands/inventory.js";
|
|
17
18
|
import { createLookCommand } from "./commands/look.js";
|
|
@@ -83,6 +84,7 @@ export function buildProgram() {
|
|
|
83
84
|
program.addCommand(createEntityCommand());
|
|
84
85
|
program.addCommand(createInventoryCommand());
|
|
85
86
|
program.addCommand(createGuiCommand());
|
|
87
|
+
program.addCommand(createImageCommand());
|
|
86
88
|
program.addCommand(createScreenshotCommand());
|
|
87
89
|
program.addCommand(createScreenCommand());
|
|
88
90
|
program.addCommand(createHudCommand());
|
|
@@ -7,6 +7,7 @@ export interface CreateClientOptions {
|
|
|
7
7
|
wsPort?: number;
|
|
8
8
|
account?: string;
|
|
9
9
|
headless?: boolean;
|
|
10
|
+
mute?: boolean;
|
|
10
11
|
launchArgs?: string[];
|
|
11
12
|
env?: Record<string, string>;
|
|
12
13
|
}
|
|
@@ -15,6 +16,7 @@ export interface LaunchClientOptions {
|
|
|
15
16
|
account?: string;
|
|
16
17
|
wsPort?: number;
|
|
17
18
|
headless?: boolean;
|
|
19
|
+
mute?: boolean;
|
|
18
20
|
force?: boolean;
|
|
19
21
|
}
|
|
20
22
|
export interface WaitReadyOptions {
|
|
@@ -67,4 +69,5 @@ export declare class ClientInstanceManager {
|
|
|
67
69
|
updateMeta(clientName: string, updates: Partial<ClientInstanceMeta>): Promise<ClientInstanceMeta>;
|
|
68
70
|
private isWsReachable;
|
|
69
71
|
private findAvailablePort;
|
|
72
|
+
private stopTrackedClient;
|
|
70
73
|
}
|
|
@@ -19,114 +19,110 @@ export class ClientInstanceManager {
|
|
|
19
19
|
this.globalState = globalState;
|
|
20
20
|
}
|
|
21
21
|
async create(options) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
return this.globalState.withClientLock(async () => {
|
|
23
|
+
const instanceDir = resolveClientInstanceDir(options.name);
|
|
24
|
+
await mkdir(instanceDir, { recursive: true });
|
|
25
|
+
const wsPort = options.wsPort ?? (await this.findAvailablePort());
|
|
26
|
+
const meta = {
|
|
27
|
+
name: options.name,
|
|
28
|
+
loader: options.loader ?? "fabric",
|
|
29
|
+
mcVersion: options.version,
|
|
30
|
+
wsPort,
|
|
31
|
+
account: options.account,
|
|
32
|
+
headless: options.headless,
|
|
33
|
+
mute: options.mute,
|
|
34
|
+
launchArgs: options.launchArgs,
|
|
35
|
+
env: options.env,
|
|
36
|
+
createdAt: new Date().toISOString()
|
|
37
|
+
};
|
|
38
|
+
await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
39
|
+
return meta;
|
|
40
|
+
});
|
|
38
41
|
}
|
|
39
42
|
async launch(clientName, options = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
return this.globalState.withClientLock(async () => {
|
|
44
|
+
const state = await this.globalState.readClientState();
|
|
45
|
+
const existing = state.clients[clientName];
|
|
46
|
+
if (existing && isProcessRunning(existing.pid)) {
|
|
47
|
+
if (options.force) {
|
|
48
|
+
await this.stopTrackedClient(state, clientName, existing);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
throw new MctError({
|
|
52
|
+
code: "CLIENT_ALREADY_RUNNING",
|
|
53
|
+
message: `Client ${clientName} is already running. Pass --force to kill and relaunch.`,
|
|
54
|
+
details: existing
|
|
55
|
+
}, 3);
|
|
56
|
+
}
|
|
46
57
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
},
|
|
58
|
+
const meta = await this.loadMeta(clientName);
|
|
59
|
+
const instanceDir = resolveClientInstanceDir(clientName);
|
|
60
|
+
const wsPort = options.wsPort ?? meta.wsPort;
|
|
61
|
+
const mute = options.mute ?? meta.mute;
|
|
62
|
+
if (!meta.launchArgs || meta.launchArgs.length === 0) {
|
|
63
|
+
throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
|
|
53
64
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const wsPort = options.wsPort ?? meta.wsPort;
|
|
58
|
-
if (!meta.launchArgs || meta.launchArgs.length === 0) {
|
|
59
|
-
throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
|
|
60
|
-
}
|
|
61
|
-
// Kill any existing processes on the port
|
|
62
|
-
const listeningPids = getListeningPids(wsPort);
|
|
63
|
-
for (const pid of listeningPids) {
|
|
64
|
-
killProcessTree(pid);
|
|
65
|
-
}
|
|
66
|
-
if (listeningPids.length > 0) {
|
|
67
|
-
const deadline = Date.now() + 10_000;
|
|
68
|
-
while (Date.now() < deadline) {
|
|
69
|
-
if (getListeningPids(wsPort).length === 0) {
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
65
|
+
const listeningPids = getListeningPids(wsPort);
|
|
66
|
+
for (const pid of listeningPids) {
|
|
67
|
+
killProcessTree(pid);
|
|
73
68
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
cwd: minecraftDir,
|
|
83
|
-
detached: true,
|
|
84
|
-
stdio: ["ignore", stdout, stdout],
|
|
85
|
-
env: {
|
|
86
|
-
...process.env,
|
|
87
|
-
...meta.env,
|
|
88
|
-
MCT_CLIENT_NAME: clientName,
|
|
89
|
-
MCT_CLIENT_VERSION: meta.mcVersion,
|
|
90
|
-
MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
|
|
91
|
-
MCT_CLIENT_SERVER: options.server ?? "",
|
|
92
|
-
MCT_CLIENT_WS_PORT: String(wsPort),
|
|
93
|
-
MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false)
|
|
69
|
+
if (listeningPids.length > 0) {
|
|
70
|
+
const deadline = Date.now() + 10_000;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (getListeningPids(wsPort).length === 0) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
76
|
+
}
|
|
94
77
|
}
|
|
78
|
+
const launchCommand = [process.execPath, getLaunchScriptPath(), ...meta.launchArgs];
|
|
79
|
+
const minecraftDir = path.join(instanceDir, "minecraft");
|
|
80
|
+
const logsDir = path.join(resolveMctHome(), "logs");
|
|
81
|
+
mkdirSync(logsDir, { recursive: true });
|
|
82
|
+
const logPath = path.join(logsDir, `client-${clientName}.log`);
|
|
83
|
+
const stdout = openSync(logPath, "a");
|
|
84
|
+
const child = spawn(launchCommand[0], launchCommand.slice(1), {
|
|
85
|
+
cwd: minecraftDir,
|
|
86
|
+
detached: true,
|
|
87
|
+
stdio: ["ignore", stdout, stdout],
|
|
88
|
+
env: {
|
|
89
|
+
...process.env,
|
|
90
|
+
...meta.env,
|
|
91
|
+
MCT_CLIENT_NAME: clientName,
|
|
92
|
+
MCT_CLIENT_VERSION: meta.mcVersion,
|
|
93
|
+
MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
|
|
94
|
+
MCT_CLIENT_SERVER: options.server ?? "",
|
|
95
|
+
MCT_CLIENT_WS_PORT: String(wsPort),
|
|
96
|
+
MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false),
|
|
97
|
+
...(mute === undefined ? {} : { MCT_CLIENT_MUTE: String(mute) })
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
child.unref();
|
|
101
|
+
const entry = {
|
|
102
|
+
pid: child.pid ?? 0,
|
|
103
|
+
name: clientName,
|
|
104
|
+
wsPort,
|
|
105
|
+
startedAt: new Date().toISOString(),
|
|
106
|
+
logPath,
|
|
107
|
+
instanceDir
|
|
108
|
+
};
|
|
109
|
+
state.defaultClient ??= clientName;
|
|
110
|
+
state.clients[clientName] = entry;
|
|
111
|
+
await this.globalState.writeClientState(state);
|
|
112
|
+
return entry;
|
|
95
113
|
});
|
|
96
|
-
child.unref();
|
|
97
|
-
const entry = {
|
|
98
|
-
pid: child.pid ?? 0,
|
|
99
|
-
name: clientName,
|
|
100
|
-
wsPort,
|
|
101
|
-
startedAt: new Date().toISOString(),
|
|
102
|
-
logPath,
|
|
103
|
-
instanceDir
|
|
104
|
-
};
|
|
105
|
-
state.defaultClient ??= clientName;
|
|
106
|
-
state.clients[clientName] = entry;
|
|
107
|
-
await this.globalState.writeClientState(state);
|
|
108
|
-
return entry;
|
|
109
114
|
}
|
|
110
115
|
async stop(clientName) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (isProcessRunning(entry.pid)) {
|
|
117
|
-
killProcessTree(entry.pid);
|
|
118
|
-
}
|
|
119
|
-
for (const pid of getListeningPids(entry.wsPort)) {
|
|
120
|
-
if (pid !== entry.pid) {
|
|
121
|
-
killProcessTree(pid);
|
|
116
|
+
return this.globalState.withClientLock(async () => {
|
|
117
|
+
const state = await this.globalState.readClientState();
|
|
118
|
+
const entry = state.clients[clientName];
|
|
119
|
+
if (!entry) {
|
|
120
|
+
return { stopped: false, alreadyStopped: true, name: clientName };
|
|
122
121
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
await this.globalState.writeClientState(state);
|
|
129
|
-
return { stopped: true, name: clientName, pid: entry.pid };
|
|
122
|
+
await this.stopTrackedClient(state, clientName, entry);
|
|
123
|
+
await this.globalState.writeClientState(state);
|
|
124
|
+
return { stopped: true, name: clientName, pid: entry.pid };
|
|
125
|
+
});
|
|
130
126
|
}
|
|
131
127
|
async isAlreadyRunning(clientName) {
|
|
132
128
|
const state = await this.globalState.readClientState();
|
|
@@ -340,4 +336,18 @@ export class ClientInstanceManager {
|
|
|
340
336
|
}
|
|
341
337
|
return port;
|
|
342
338
|
}
|
|
339
|
+
async stopTrackedClient(state, clientName, entry) {
|
|
340
|
+
if (isProcessRunning(entry.pid)) {
|
|
341
|
+
killProcessTree(entry.pid);
|
|
342
|
+
}
|
|
343
|
+
for (const pid of getListeningPids(entry.wsPort)) {
|
|
344
|
+
if (pid !== entry.pid) {
|
|
345
|
+
killProcessTree(pid);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
delete state.clients[clientName];
|
|
349
|
+
if (state.defaultClient === clientName) {
|
|
350
|
+
state.defaultClient = Object.keys(state.clients)[0];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
343
353
|
}
|
|
@@ -70,6 +70,10 @@ export declare class ServerInstanceManager {
|
|
|
70
70
|
reachable: boolean;
|
|
71
71
|
host: string;
|
|
72
72
|
port: number;
|
|
73
|
+
phase: string;
|
|
74
|
+
logPath: string;
|
|
75
|
+
lastLine: string | null;
|
|
76
|
+
recentLines: string[];
|
|
73
77
|
}>;
|
|
74
78
|
exec(serverName: string, command: string): Promise<{
|
|
75
79
|
sent: boolean;
|
|
@@ -99,6 +103,7 @@ export declare class ServerInstanceManager {
|
|
|
99
103
|
timedOut: boolean;
|
|
100
104
|
}>;
|
|
101
105
|
private requireRuntimeEntry;
|
|
106
|
+
private describeStartup;
|
|
102
107
|
private requireRunning;
|
|
103
108
|
list(): Promise<ServerInstanceMeta[]>;
|
|
104
109
|
static listAll(globalState: GlobalStateStore): Promise<ServerInstanceMeta[]>;
|
|
@@ -4,11 +4,12 @@ import { spawn, execSync } from "node:child_process";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { resolveMctHome, resolveProjectDir, resolveServerInstanceDir } from "../util/paths.js";
|
|
6
6
|
import { MctError } from "../util/errors.js";
|
|
7
|
-
import {
|
|
7
|
+
import { isTcpPortReachable } from "../util/net.js";
|
|
8
8
|
import { isProcessRunning, killProcessTree } from "../util/process.js";
|
|
9
9
|
import { copyFileIfMissing } from "../download/DownloadUtils.js";
|
|
10
10
|
const INSTANCE_FILE = "instance.json";
|
|
11
11
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
12
|
+
const SERVER_READY_POLL_MS = 500;
|
|
12
13
|
export function stripAnsiCodes(text) {
|
|
13
14
|
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
14
15
|
}
|
|
@@ -108,7 +109,7 @@ export class ServerInstanceManager {
|
|
|
108
109
|
// then exec java with stdin reading from the FIFO
|
|
109
110
|
const child = spawn("bash", [
|
|
110
111
|
"-c",
|
|
111
|
-
'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@"
|
|
112
|
+
'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@" 0<&3',
|
|
112
113
|
"mct-server",
|
|
113
114
|
...jvmArgs, "-jar", jarFile, "nogui"
|
|
114
115
|
], {
|
|
@@ -206,7 +207,51 @@ export class ServerInstanceManager {
|
|
|
206
207
|
if (!entry) {
|
|
207
208
|
throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${stateKey} is not running` }, 5);
|
|
208
209
|
}
|
|
209
|
-
|
|
210
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
211
|
+
let snapshot = await this.describeStartup(entry.logPath);
|
|
212
|
+
while (Date.now() < deadline) {
|
|
213
|
+
if (!isProcessRunning(entry.pid)) {
|
|
214
|
+
throw new MctError({
|
|
215
|
+
code: "SERVER_EXITED",
|
|
216
|
+
message: `Server ${stateKey} exited before becoming ready (${snapshot.phase})`,
|
|
217
|
+
details: {
|
|
218
|
+
pid: entry.pid,
|
|
219
|
+
host: "127.0.0.1",
|
|
220
|
+
port: entry.port,
|
|
221
|
+
phase: snapshot.phase,
|
|
222
|
+
logPath: snapshot.logPath,
|
|
223
|
+
lastLine: snapshot.lastLine,
|
|
224
|
+
recentLines: snapshot.recentLines
|
|
225
|
+
}
|
|
226
|
+
}, 5);
|
|
227
|
+
}
|
|
228
|
+
if (await isTcpPortReachable("127.0.0.1", entry.port)) {
|
|
229
|
+
snapshot = await this.describeStartup(entry.logPath);
|
|
230
|
+
return {
|
|
231
|
+
reachable: true,
|
|
232
|
+
host: "127.0.0.1",
|
|
233
|
+
port: entry.port,
|
|
234
|
+
phase: snapshot.phase,
|
|
235
|
+
logPath: snapshot.logPath,
|
|
236
|
+
lastLine: snapshot.lastLine,
|
|
237
|
+
recentLines: snapshot.recentLines
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
snapshot = await this.describeStartup(entry.logPath);
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, SERVER_READY_POLL_MS));
|
|
242
|
+
}
|
|
243
|
+
throw new MctError({
|
|
244
|
+
code: "TIMEOUT",
|
|
245
|
+
message: `Timed out waiting for 127.0.0.1:${entry.port} (${snapshot.phase})`,
|
|
246
|
+
details: {
|
|
247
|
+
host: "127.0.0.1",
|
|
248
|
+
port: entry.port,
|
|
249
|
+
phase: snapshot.phase,
|
|
250
|
+
logPath: snapshot.logPath,
|
|
251
|
+
lastLine: snapshot.lastLine,
|
|
252
|
+
recentLines: snapshot.recentLines
|
|
253
|
+
}
|
|
254
|
+
}, 2);
|
|
210
255
|
}
|
|
211
256
|
async exec(serverName, command) {
|
|
212
257
|
const entry = await this.requireRunning(serverName);
|
|
@@ -338,6 +383,21 @@ export class ServerInstanceManager {
|
|
|
338
383
|
}
|
|
339
384
|
return entry;
|
|
340
385
|
}
|
|
386
|
+
async describeStartup(logPath) {
|
|
387
|
+
const raw = await readFile(logPath, "utf8").catch(() => "");
|
|
388
|
+
const recentLines = raw
|
|
389
|
+
.split(/\r?\n/)
|
|
390
|
+
.map((line) => stripAnsiCodes(line).trim())
|
|
391
|
+
.filter((line) => line.length > 0)
|
|
392
|
+
.slice(-10);
|
|
393
|
+
const lastLine = recentLines[recentLines.length - 1] ?? null;
|
|
394
|
+
return {
|
|
395
|
+
phase: detectServerStartupPhase(recentLines),
|
|
396
|
+
logPath,
|
|
397
|
+
recentLines,
|
|
398
|
+
lastLine
|
|
399
|
+
};
|
|
400
|
+
}
|
|
341
401
|
async requireRunning(serverName) {
|
|
342
402
|
const entry = await this.requireRuntimeEntry(serverName);
|
|
343
403
|
if (!isProcessRunning(entry.pid)) {
|
|
@@ -430,3 +490,25 @@ export class ServerInstanceManager {
|
|
|
430
490
|
return port;
|
|
431
491
|
}
|
|
432
492
|
}
|
|
493
|
+
function detectServerStartupPhase(lines) {
|
|
494
|
+
const joined = lines.join("\n");
|
|
495
|
+
if (/Done \(.+\)! For help, type "help"/.test(joined)) {
|
|
496
|
+
return "ready";
|
|
497
|
+
}
|
|
498
|
+
if (/Preparing start region|Preparing level/.test(joined)) {
|
|
499
|
+
return "initializing-world";
|
|
500
|
+
}
|
|
501
|
+
if (/Starting Minecraft server on/.test(joined)) {
|
|
502
|
+
return "binding-port";
|
|
503
|
+
}
|
|
504
|
+
if (/Loading libraries, please wait|Starting org\.bukkit\.craftbukkit\.Main|Starting minecraft server version/.test(joined)) {
|
|
505
|
+
return "bootstrapping";
|
|
506
|
+
}
|
|
507
|
+
if (/Downloading |Applying patches/.test(joined)) {
|
|
508
|
+
return "downloading";
|
|
509
|
+
}
|
|
510
|
+
if (lines.length > 0) {
|
|
511
|
+
return "starting";
|
|
512
|
+
}
|
|
513
|
+
return "waiting-for-log";
|
|
514
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface CommandLogEntry {
|
|
2
|
+
t: number;
|
|
3
|
+
iso: string;
|
|
4
|
+
argv: string[];
|
|
5
|
+
cwd: string;
|
|
6
|
+
projectId: string | null;
|
|
7
|
+
exitCode: number;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
errorCode?: string;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function appendCommandHistory(entry: CommandLogEntry): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveCommandsLogPath } from "./paths.js";
|
|
4
|
+
export async function appendCommandHistory(entry) {
|
|
5
|
+
try {
|
|
6
|
+
const filePath = resolveCommandsLogPath();
|
|
7
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
8
|
+
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Never block the command on logging failure
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -2,8 +2,12 @@ import { StateStore } from "./state.js";
|
|
|
2
2
|
import type { GlobalClientState, GlobalServerState } from "./instance-types.js";
|
|
3
3
|
export declare class GlobalStateStore extends StateStore {
|
|
4
4
|
constructor();
|
|
5
|
+
withClientLock<T>(task: () => Promise<T>): Promise<T>;
|
|
6
|
+
withServerLock<T>(task: () => Promise<T>): Promise<T>;
|
|
5
7
|
readServerState(): Promise<GlobalServerState>;
|
|
6
8
|
writeServerState(state: GlobalServerState): Promise<void>;
|
|
7
9
|
readClientState(): Promise<GlobalClientState>;
|
|
8
10
|
writeClientState(state: GlobalClientState): Promise<void>;
|
|
11
|
+
updateClientState<T>(mutate: (state: GlobalClientState) => Promise<T> | T): Promise<T>;
|
|
12
|
+
updateServerState<T>(mutate: (state: GlobalServerState) => Promise<T> | T): Promise<T>;
|
|
9
13
|
}
|