@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.
@@ -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 { prepareManagedFabricRuntime } from "./FabricRuntimeDownloader.js";
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.loader !== "fabric") {
18
+ if (variant.support !== "ready" && variant.support !== "configured") {
19
19
  throw new MctError({
20
- code: "UNSUPPORTED_LOADER",
21
- message: `Loader ${variant.loader} is not implemented yet`
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 (!variant.fabricLoaderVersion || !variant.yarnMappings) {
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.0";
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 ?? prepareManagedFabricRuntime;
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
- : getDefaultVariant(catalog);
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
- export async function prepareManagedFabricRuntime(variant, runtimeOptions, dependencies = {}) {
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 installFabric({
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
- const instanceDir = resolveClientInstanceDir(options.name);
23
- await mkdir(instanceDir, { recursive: true });
24
- const wsPort = options.wsPort ?? (await this.findAvailablePort());
25
- const meta = {
26
- name: options.name,
27
- loader: options.loader ?? "fabric",
28
- mcVersion: options.version,
29
- wsPort,
30
- account: options.account,
31
- headless: options.headless,
32
- launchArgs: options.launchArgs,
33
- env: options.env,
34
- createdAt: new Date().toISOString()
35
- };
36
- await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
37
- return meta;
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
- let state = await this.globalState.readClientState();
41
- const existing = state.clients[clientName];
42
- if (existing && isProcessRunning(existing.pid)) {
43
- if (options.force) {
44
- await this.stop(clientName);
45
- state = await this.globalState.readClientState();
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
- else {
48
- throw new MctError({
49
- code: "CLIENT_ALREADY_RUNNING",
50
- message: `Client ${clientName} is already running. Pass --force to kill and relaunch.`,
51
- details: existing
52
- }, 3);
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
- const meta = await this.loadMeta(clientName);
56
- const instanceDir = resolveClientInstanceDir(clientName);
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
- const launchCommand = [process.execPath, getLaunchScriptPath(), ...meta.launchArgs];
76
- const minecraftDir = path.join(instanceDir, "minecraft");
77
- const logsDir = path.join(resolveMctHome(), "logs");
78
- mkdirSync(logsDir, { recursive: true });
79
- const logPath = path.join(logsDir, `client-${clientName}.log`);
80
- const stdout = openSync(logPath, "a");
81
- const child = spawn(launchCommand[0], launchCommand.slice(1), {
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
- const state = await this.globalState.readClientState();
112
- const entry = state.clients[clientName];
113
- if (!entry) {
114
- return { stopped: false, alreadyStopped: true, name: clientName };
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
- delete state.clients[clientName];
125
- if (state.defaultClient === clientName) {
126
- state.defaultClient = Object.keys(state.clients)[0];
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 { waitForTcpPort } from "../util/net.js";
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 "$@" <"$MCT_STDIN_PIPE"',
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
- return waitForTcpPort("127.0.0.1", entry.port, timeoutSeconds);
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
  }