@kzheart_/mc-pilot 0.9.0 → 0.9.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.
@@ -9,7 +9,7 @@
9
9
  "loader": "fabric",
10
10
  "support": "ready",
11
11
  "validation": "verified",
12
- "modVersion": "0.1.1",
12
+ "modVersion": "0.9.1",
13
13
  "fabricLoaderVersion": "0.16.14",
14
14
  "yarnMappings": "1.18.2+build.4",
15
15
  "javaVersion": 17,
@@ -21,7 +21,7 @@
21
21
  "loader": "fabric",
22
22
  "support": "ready",
23
23
  "validation": "verified",
24
- "modVersion": "0.1.1",
24
+ "modVersion": "0.9.1",
25
25
  "fabricLoaderVersion": "0.16.14",
26
26
  "yarnMappings": "1.20.1+build.10",
27
27
  "javaVersion": 17,
@@ -33,7 +33,7 @@
33
33
  "loader": "forge",
34
34
  "support": "planned",
35
35
  "validation": "planned",
36
- "modVersion": "0.1.1",
36
+ "modVersion": "0.9.1",
37
37
  "forgeVersion": "47.3.0",
38
38
  "javaVersion": 17
39
39
  },
@@ -43,7 +43,7 @@
43
43
  "loader": "fabric",
44
44
  "support": "ready",
45
45
  "validation": "verified",
46
- "modVersion": "0.1.1",
46
+ "modVersion": "0.9.1",
47
47
  "fabricLoaderVersion": "0.16.14",
48
48
  "yarnMappings": "1.20.2+build.4",
49
49
  "javaVersion": 17,
@@ -55,7 +55,7 @@
55
55
  "loader": "forge",
56
56
  "support": "planned",
57
57
  "validation": "planned",
58
- "modVersion": "0.1.1",
58
+ "modVersion": "0.9.1",
59
59
  "forgeVersion": "48.1.0",
60
60
  "javaVersion": 17
61
61
  },
@@ -65,7 +65,7 @@
65
65
  "loader": "fabric",
66
66
  "support": "ready",
67
67
  "validation": "verified",
68
- "modVersion": "0.1.1",
68
+ "modVersion": "0.9.1",
69
69
  "fabricLoaderVersion": "0.16.14",
70
70
  "yarnMappings": "1.20.4+build.3",
71
71
  "javaVersion": 17,
@@ -77,7 +77,7 @@
77
77
  "loader": "forge",
78
78
  "support": "planned",
79
79
  "validation": "planned",
80
- "modVersion": "0.1.1",
80
+ "modVersion": "0.9.1",
81
81
  "forgeVersion": "49.0.49",
82
82
  "javaVersion": 17
83
83
  },
@@ -87,7 +87,7 @@
87
87
  "loader": "neoforge",
88
88
  "support": "planned",
89
89
  "validation": "planned",
90
- "modVersion": "0.1.1",
90
+ "modVersion": "0.9.1",
91
91
  "neoforgeVersion": "20.4.237",
92
92
  "javaVersion": 17
93
93
  },
@@ -97,7 +97,7 @@
97
97
  "loader": "fabric",
98
98
  "support": "ready",
99
99
  "validation": "verified",
100
- "modVersion": "0.1.1",
100
+ "modVersion": "0.9.1",
101
101
  "fabricLoaderVersion": "0.16.14",
102
102
  "yarnMappings": "1.21.1+build.3",
103
103
  "javaVersion": 21,
@@ -109,7 +109,7 @@
109
109
  "loader": "neoforge",
110
110
  "support": "planned",
111
111
  "validation": "planned",
112
- "modVersion": "0.1.1",
112
+ "modVersion": "0.9.1",
113
113
  "neoforgeVersion": "21.1.77",
114
114
  "javaVersion": 21
115
115
  },
@@ -119,7 +119,7 @@
119
119
  "loader": "fabric",
120
120
  "support": "ready",
121
121
  "validation": "verified",
122
- "modVersion": "0.1.1",
122
+ "modVersion": "0.9.1",
123
123
  "fabricLoaderVersion": "0.16.14",
124
124
  "yarnMappings": "1.21.4+build.1",
125
125
  "javaVersion": 21,
@@ -131,7 +131,7 @@
131
131
  "loader": "neoforge",
132
132
  "support": "planned",
133
133
  "validation": "planned",
134
- "modVersion": "0.1.1",
134
+ "modVersion": "0.9.1",
135
135
  "neoforgeVersion": "21.4.75",
136
136
  "javaVersion": 21
137
137
  }
@@ -91,7 +91,7 @@ export function createClientCommand() {
91
91
  sourcePath = cacheArtifactPath;
92
92
  }
93
93
  catch {
94
- const modVersion = variant.modVersion ?? "0.1.0";
94
+ const modVersion = variant.modVersion ?? "0.9.1";
95
95
  const baseUrl = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
96
96
  const downloadUrl = `${baseUrl}/v${modVersion}/${artifactFileName}`;
97
97
  await downloadFile(downloadUrl, cacheArtifactPath, fetch);
@@ -10,9 +10,9 @@ const VERSION_MATRIX = [
10
10
  spigot: { supported: true, requiresBuildTools: true }
11
11
  },
12
12
  clients: {
13
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
13
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
14
14
  forge: { supported: false, notes: "不支持此版本" },
15
- neoforge: { supported: true, loaderVersion: "21.4.x", modVersion: "0.1.1" }
15
+ neoforge: { supported: true, loaderVersion: "21.4.x", modVersion: "0.9.1" }
16
16
  }
17
17
  },
18
18
  {
@@ -25,9 +25,9 @@ const VERSION_MATRIX = [
25
25
  spigot: { supported: true, requiresBuildTools: true }
26
26
  },
27
27
  clients: {
28
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
28
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
29
29
  forge: { supported: false, notes: "不支持此版本" },
30
- neoforge: { supported: true, loaderVersion: "21.1.x", modVersion: "0.1.1" }
30
+ neoforge: { supported: true, loaderVersion: "21.1.x", modVersion: "0.9.1" }
31
31
  }
32
32
  },
33
33
  {
@@ -40,8 +40,8 @@ const VERSION_MATRIX = [
40
40
  spigot: { supported: true, requiresBuildTools: true }
41
41
  },
42
42
  clients: {
43
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
44
- forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.1.1" },
43
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
44
+ forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.9.1" },
45
45
  neoforge: { supported: false, notes: "不支持此版本" }
46
46
  }
47
47
  },
@@ -55,7 +55,7 @@ const VERSION_MATRIX = [
55
55
  spigot: { supported: true, requiresBuildTools: true }
56
56
  },
57
57
  clients: {
58
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
58
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
59
59
  forge: { supported: false, notes: "当前未接入此 loader" },
60
60
  neoforge: { supported: false, notes: "不支持此版本" }
61
61
  }
@@ -70,7 +70,7 @@ const VERSION_MATRIX = [
70
70
  spigot: { supported: true, requiresBuildTools: true }
71
71
  },
72
72
  clients: {
73
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
73
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
74
74
  forge: { supported: false, notes: "当前未接入此 loader" },
75
75
  neoforge: { supported: false, notes: "不支持此版本" }
76
76
  }
@@ -85,8 +85,8 @@ const VERSION_MATRIX = [
85
85
  spigot: { supported: true, requiresBuildTools: true }
86
86
  },
87
87
  clients: {
88
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
89
- forge: { supported: true, loaderVersion: "47.x", modVersion: "0.1.1" },
88
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
89
+ forge: { supported: true, loaderVersion: "47.x", modVersion: "0.9.1" },
90
90
  neoforge: { supported: false, notes: "不支持此版本" }
91
91
  }
92
92
  },
@@ -100,8 +100,8 @@ const VERSION_MATRIX = [
100
100
  spigot: { supported: true, requiresBuildTools: true }
101
101
  },
102
102
  clients: {
103
- fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
104
- forge: { supported: true, loaderVersion: "40.x", modVersion: "0.1.1" },
103
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
104
+ forge: { supported: true, loaderVersion: "40.x", modVersion: "0.9.1" },
105
105
  neoforge: { supported: false, notes: "不支持此版本" }
106
106
  }
107
107
  },
@@ -116,7 +116,7 @@ const VERSION_MATRIX = [
116
116
  },
117
117
  clients: {
118
118
  fabric: { supported: false, notes: "当前未接入此版本 mod" },
119
- forge: { supported: true, loaderVersion: "36.x", modVersion: "0.1.1" },
119
+ forge: { supported: true, loaderVersion: "36.x", modVersion: "0.9.1" },
120
120
  neoforge: { supported: false, notes: "不支持此版本" }
121
121
  }
122
122
  },
@@ -131,7 +131,7 @@ const VERSION_MATRIX = [
131
131
  },
132
132
  clients: {
133
133
  fabric: { supported: false, notes: "不支持此版本" },
134
- forge: { supported: true, loaderVersion: "14.23.x", modVersion: "0.1.1" },
134
+ forge: { supported: true, loaderVersion: "14.23.x", modVersion: "0.9.1" },
135
135
  neoforge: { supported: false, notes: "不支持此版本" }
136
136
  }
137
137
  }
@@ -51,7 +51,7 @@ export async function resolveArtifact(cwd, variant, cacheManager, fetchImpl = fe
51
51
  }
52
52
  catch { }
53
53
  // 3. Download from GitHub Releases
54
- const modVersion = variant.modVersion ?? "0.1.0";
54
+ const modVersion = variant.modVersion ?? "0.9.1";
55
55
  const releaseTag = `v${modVersion}`;
56
56
  const downloadUrl = `${GITHUB_RELEASE_BASE_URL}/${releaseTag}/${artifactFileName}`;
57
57
  try {
@@ -67,4 +67,5 @@ export declare class ClientInstanceManager {
67
67
  updateMeta(clientName: string, updates: Partial<ClientInstanceMeta>): Promise<ClientInstanceMeta>;
68
68
  private isWsReachable;
69
69
  private findAvailablePort;
70
+ private stopTrackedClient;
70
71
  }
@@ -19,114 +19,107 @@ 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
+ launchArgs: options.launchArgs,
34
+ env: options.env,
35
+ createdAt: new Date().toISOString()
36
+ };
37
+ await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
38
+ return meta;
39
+ });
38
40
  }
39
41
  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();
42
+ return this.globalState.withClientLock(async () => {
43
+ const state = await this.globalState.readClientState();
44
+ const existing = state.clients[clientName];
45
+ if (existing && isProcessRunning(existing.pid)) {
46
+ if (options.force) {
47
+ await this.stopTrackedClient(state, clientName, existing);
48
+ }
49
+ else {
50
+ throw new MctError({
51
+ code: "CLIENT_ALREADY_RUNNING",
52
+ message: `Client ${clientName} is already running. Pass --force to kill and relaunch.`,
53
+ details: existing
54
+ }, 3);
55
+ }
46
56
  }
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);
57
+ const meta = await this.loadMeta(clientName);
58
+ const instanceDir = resolveClientInstanceDir(clientName);
59
+ const wsPort = options.wsPort ?? meta.wsPort;
60
+ if (!meta.launchArgs || meta.launchArgs.length === 0) {
61
+ throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
53
62
  }
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));
63
+ const listeningPids = getListeningPids(wsPort);
64
+ for (const pid of listeningPids) {
65
+ killProcessTree(pid);
73
66
  }
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)
67
+ if (listeningPids.length > 0) {
68
+ const deadline = Date.now() + 10_000;
69
+ while (Date.now() < deadline) {
70
+ if (getListeningPids(wsPort).length === 0) {
71
+ break;
72
+ }
73
+ await new Promise((resolve) => setTimeout(resolve, 250));
74
+ }
94
75
  }
76
+ const launchCommand = [process.execPath, getLaunchScriptPath(), ...meta.launchArgs];
77
+ const minecraftDir = path.join(instanceDir, "minecraft");
78
+ const logsDir = path.join(resolveMctHome(), "logs");
79
+ mkdirSync(logsDir, { recursive: true });
80
+ const logPath = path.join(logsDir, `client-${clientName}.log`);
81
+ const stdout = openSync(logPath, "a");
82
+ const child = spawn(launchCommand[0], launchCommand.slice(1), {
83
+ cwd: minecraftDir,
84
+ detached: true,
85
+ stdio: ["ignore", stdout, stdout],
86
+ env: {
87
+ ...process.env,
88
+ ...meta.env,
89
+ MCT_CLIENT_NAME: clientName,
90
+ MCT_CLIENT_VERSION: meta.mcVersion,
91
+ MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
92
+ MCT_CLIENT_SERVER: options.server ?? "",
93
+ MCT_CLIENT_WS_PORT: String(wsPort),
94
+ MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false)
95
+ }
96
+ });
97
+ child.unref();
98
+ const entry = {
99
+ pid: child.pid ?? 0,
100
+ name: clientName,
101
+ wsPort,
102
+ startedAt: new Date().toISOString(),
103
+ logPath,
104
+ instanceDir
105
+ };
106
+ state.defaultClient ??= clientName;
107
+ state.clients[clientName] = entry;
108
+ await this.globalState.writeClientState(state);
109
+ return entry;
95
110
  });
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
111
  }
110
112
  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);
113
+ return this.globalState.withClientLock(async () => {
114
+ const state = await this.globalState.readClientState();
115
+ const entry = state.clients[clientName];
116
+ if (!entry) {
117
+ return { stopped: false, alreadyStopped: true, name: clientName };
122
118
  }
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 };
119
+ await this.stopTrackedClient(state, clientName, entry);
120
+ await this.globalState.writeClientState(state);
121
+ return { stopped: true, name: clientName, pid: entry.pid };
122
+ });
130
123
  }
131
124
  async isAlreadyRunning(clientName) {
132
125
  const state = await this.globalState.readClientState();
@@ -340,4 +333,18 @@ export class ClientInstanceManager {
340
333
  }
341
334
  return port;
342
335
  }
336
+ async stopTrackedClient(state, clientName, entry) {
337
+ if (isProcessRunning(entry.pid)) {
338
+ killProcessTree(entry.pid);
339
+ }
340
+ for (const pid of getListeningPids(entry.wsPort)) {
341
+ if (pid !== entry.pid) {
342
+ killProcessTree(pid);
343
+ }
344
+ }
345
+ delete state.clients[clientName];
346
+ if (state.defaultClient === clientName) {
347
+ state.defaultClient = Object.keys(state.clients)[0];
348
+ }
349
+ }
343
350
  }
@@ -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
  }
@@ -6,6 +6,12 @@ export class GlobalStateStore extends StateStore {
6
6
  constructor() {
7
7
  super(resolveGlobalStateDir());
8
8
  }
9
+ async withClientLock(task) {
10
+ return this.withLock("clients", task);
11
+ }
12
+ async withServerLock(task) {
13
+ return this.withLock("servers", task);
14
+ }
9
15
  async readServerState() {
10
16
  return this.readJson(SERVERS_STATE_FILE, { servers: {} });
11
17
  }
@@ -18,4 +24,20 @@ export class GlobalStateStore extends StateStore {
18
24
  async writeClientState(state) {
19
25
  await this.writeJson(CLIENTS_STATE_FILE, state);
20
26
  }
27
+ async updateClientState(mutate) {
28
+ return this.withClientLock(async () => {
29
+ const state = await this.readClientState();
30
+ const result = await mutate(state);
31
+ await this.writeClientState(state);
32
+ return result;
33
+ });
34
+ }
35
+ async updateServerState(mutate) {
36
+ return this.withServerLock(async () => {
37
+ const state = await this.readServerState();
38
+ const result = await mutate(state);
39
+ await this.writeServerState(state);
40
+ return result;
41
+ });
42
+ }
21
43
  }
@@ -1,3 +1,4 @@
1
+ export declare function isTcpPortReachable(host: string, port: number): Promise<boolean>;
1
2
  export declare function waitForTcpPort(host: string, port: number, timeoutSeconds: number): Promise<{
2
3
  reachable: boolean;
3
4
  host: string;
package/dist/util/net.js CHANGED
@@ -5,21 +5,23 @@ function wait(ms) {
5
5
  setTimeout(resolve, ms);
6
6
  });
7
7
  }
8
+ export async function isTcpPortReachable(host, port) {
9
+ return await new Promise((resolve) => {
10
+ const socket = net.createConnection({ host, port });
11
+ socket.once("connect", () => {
12
+ socket.destroy();
13
+ resolve(true);
14
+ });
15
+ socket.once("error", () => {
16
+ socket.destroy();
17
+ resolve(false);
18
+ });
19
+ });
20
+ }
8
21
  export async function waitForTcpPort(host, port, timeoutSeconds) {
9
22
  const deadline = Date.now() + timeoutSeconds * 1000;
10
23
  while (Date.now() < deadline) {
11
- const reachable = await new Promise((resolve) => {
12
- const socket = net.createConnection({ host, port });
13
- socket.once("connect", () => {
14
- socket.destroy();
15
- resolve(true);
16
- });
17
- socket.once("error", () => {
18
- socket.destroy();
19
- resolve(false);
20
- });
21
- });
22
- if (reachable) {
24
+ if (await isTcpPortReachable(host, port)) {
23
25
  return {
24
26
  reachable: true,
25
27
  host,
@@ -6,5 +6,10 @@ export declare class StateStore {
6
6
  readJson<T>(name: string, fallback: T): Promise<T>;
7
7
  writeJson(name: string, value: unknown): Promise<void>;
8
8
  remove(name: string): Promise<void>;
9
+ withLock<T>(name: string, task: () => Promise<T>, options?: {
10
+ timeoutMs?: number;
11
+ staleMs?: number;
12
+ }): Promise<T>;
13
+ private cleanupStaleLock;
9
14
  }
10
15
  export declare function resolveStateDir(stateDir: string | undefined, cwd: string): string;
@@ -1,5 +1,22 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import process from "node:process";
5
+ const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
6
+ const DEFAULT_LOCK_STALE_MS = 60_000;
7
+ const LOCK_POLL_INTERVAL_MS = 50;
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function isPidRunning(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
3
20
  export class StateStore {
4
21
  rootDir;
5
22
  constructor(rootDir) {
@@ -25,12 +42,74 @@ export class StateStore {
25
42
  async writeJson(name, value) {
26
43
  await this.ensure();
27
44
  const target = path.join(this.rootDir, name);
45
+ const tempTarget = `${target}.${process.pid}.${randomUUID()}.tmp`;
28
46
  const content = JSON.stringify(value, null, 2);
29
- await writeFile(target, `${content}\n`, "utf8");
47
+ await writeFile(tempTarget, `${content}\n`, "utf8");
48
+ await rename(tempTarget, target);
30
49
  }
31
50
  async remove(name) {
32
51
  await rm(path.join(this.rootDir, name), { force: true });
33
52
  }
53
+ async withLock(name, task, options = {}) {
54
+ await this.ensure();
55
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS);
56
+ const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
57
+ const safeName = name.replace(/[\\/]/g, "-");
58
+ const lockDir = path.join(this.rootDir, `${safeName}.lock`);
59
+ const ownerPath = path.join(lockDir, "owner.json");
60
+ while (true) {
61
+ try {
62
+ await mkdir(lockDir);
63
+ const owner = {
64
+ pid: process.pid,
65
+ acquiredAt: new Date().toISOString(),
66
+ acquiredAtMs: Date.now()
67
+ };
68
+ await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8");
69
+ break;
70
+ }
71
+ catch (error) {
72
+ const lockExists = typeof error === "object"
73
+ && error !== null
74
+ && "code" in error
75
+ && error.code === "EEXIST";
76
+ if (!lockExists) {
77
+ throw error;
78
+ }
79
+ if (await this.cleanupStaleLock(lockDir, ownerPath, staleMs)) {
80
+ continue;
81
+ }
82
+ if (Date.now() >= deadline) {
83
+ throw new Error(`Timed out waiting for lock ${safeName}`);
84
+ }
85
+ await sleep(LOCK_POLL_INTERVAL_MS);
86
+ }
87
+ }
88
+ try {
89
+ return await task();
90
+ }
91
+ finally {
92
+ await rm(lockDir, { recursive: true, force: true });
93
+ }
94
+ }
95
+ async cleanupStaleLock(lockDir, ownerPath, staleMs) {
96
+ try {
97
+ const raw = await readFile(ownerPath, "utf8");
98
+ const owner = JSON.parse(raw);
99
+ const pid = Number(owner.pid);
100
+ const acquiredAtMs = typeof owner.acquiredAtMs === "number" ? owner.acquiredAtMs : NaN;
101
+ const ownerAlive = Number.isInteger(pid) && pid > 0 ? isPidRunning(pid) : false;
102
+ const isStale = Number.isFinite(acquiredAtMs) && Date.now() - acquiredAtMs > staleMs;
103
+ if (!ownerAlive || isStale) {
104
+ await rm(lockDir, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+ }
108
+ catch {
109
+ // The owner file may not exist yet while another process is finalizing lock acquisition.
110
+ }
111
+ return false;
112
+ }
34
113
  }
35
114
  export function resolveStateDir(stateDir, cwd) {
36
115
  if (!stateDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Minecraft plugin/mod automated testing CLI – control a real Minecraft client to simulate player actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -126,9 +126,17 @@ async function readJson(filePath) {
126
126
  return JSON.parse(await readFile(filePath, "utf8"));
127
127
  }
128
128
 
129
- async function syncBuiltMod(instanceRoot, repoRoot, variantId) {
130
- const artifactName = `mct-client-mod-${variantId.endsWith("-fabric") ? "fabric" : variantId.split("-").pop()}-${variantId.replace(/-fabric$|-forge$|-neoforge$/, "")}.jar`;
131
- const sourceJar = path.join(repoRoot, "client-mod", "versions", variantId, "build", "libs", artifactName);
129
+ function getLocalBuildArtifactPath(repoRoot, variant) {
130
+ const artifactName = `mct-client-mod-${variant.loader ?? "fabric"}-${variant.minecraftVersion}.jar`;
131
+ const gradleModule = variant.gradleModule || `version-${variant.minecraftVersion}`;
132
+ return {
133
+ artifactName,
134
+ sourceJar: path.join(repoRoot, "client-mod", gradleModule, "build", "libs", artifactName)
135
+ };
136
+ }
137
+
138
+ async function syncBuiltMod(instanceRoot, repoRoot, variant) {
139
+ const { artifactName, sourceJar } = getLocalBuildArtifactPath(repoRoot, variant);
132
140
  const targetDir = path.join(instanceRoot, "minecraft", "mods");
133
141
  const targetJar = path.join(targetDir, artifactName);
134
142
 
@@ -193,7 +201,7 @@ async function ensureAutomationOptions(gameDir, server) {
193
201
  }
194
202
 
195
203
  async function buildLaunchSpec(options) {
196
- const repoRoot = process.cwd();
204
+ const repoRoot = path.resolve(__dirname, "..", "..");
197
205
  const defaultVariant = getDefaultVariant();
198
206
  const minecraftVersion = process.env.MCT_CLIENT_VERSION || options["minecraft-version"] || defaultVariant.minecraftVersion;
199
207
  const modVariantId = process.env.MCT_CLIENT_MOD_VARIANT || options["mod-variant"] || `${minecraftVersion}-fabric`;
@@ -206,7 +214,7 @@ async function buildLaunchSpec(options) {
206
214
  const nativesDir = options["natives-dir"] || path.join(instanceRoot, "natives");
207
215
  const gameDir = path.join(instanceRoot, "minecraft");
208
216
  const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
209
- await syncBuiltMod(instanceRoot, repoRoot, modVariantId);
217
+ await syncBuiltMod(instanceRoot, repoRoot, selectedVariant);
210
218
  const componentMetas = new Map();
211
219
  for (const component of packMeta.components) {
212
220
  const componentMetaPath = path.join(metaRoot, component.uid, `${component.version}.json`);