@kzheart_/mc-pilot 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,79 @@
1
1
  import { Command } from "commander";
2
- import { createRequestAction, withTransportTimeoutBuffer } from "./request-helpers.js";
2
+ import { ServerInstanceManager } from "../instance/ServerInstanceManager.js";
3
+ import { MctError } from "../util/errors.js";
4
+ import { wrapCommand } from "../util/command.js";
5
+ import { createRequestAction, resolvePreferredClientName, sendClientRequest, withTransportTimeoutBuffer } from "./request-helpers.js";
6
+ function normalizeChatCommand(text) {
7
+ const command = text?.trim();
8
+ if (!command) {
9
+ throw new MctError({ code: "INVALID_PARAMS", message: "Command is required" }, 4);
10
+ }
11
+ return command;
12
+ }
13
+ async function executeServerCommand(context, serverName, command) {
14
+ const manager = new ServerInstanceManager(context.globalState, context.projectName);
15
+ const result = await manager.exec(serverName, command);
16
+ return {
17
+ ...result,
18
+ warning: "Commands that require a player sender should use --via client."
19
+ };
20
+ }
3
21
  export function createChatCommand() {
4
22
  const command = new Command("chat").description("Chat and server commands");
5
23
  command
6
24
  .command("send")
7
- .description("Send a chat message")
25
+ .description("Send a chat message. Slash-prefixed text is routed as a player command unless --literal is set.")
8
26
  .argument("<message>", "Message text")
9
- .action(createRequestAction("chat.send", ({ args }) => ({ message: args[0] })));
27
+ .option("--literal", "Send slash-prefixed text as plain chat instead of a command packet")
28
+ .action(wrapCommand(async (context, { args, options, globalOptions }) => {
29
+ const message = args[0] ?? "";
30
+ const preferredClient = resolvePreferredClientName(context, globalOptions);
31
+ if (!options.literal && message.trim().startsWith("/")) {
32
+ return sendClientRequest(context, preferredClient, "chat.command", { command: message });
33
+ }
34
+ return sendClientRequest(context, preferredClient, "chat.send", { message });
35
+ }));
10
36
  command
11
37
  .command("command")
12
- .description("Execute a server command (no / prefix needed)")
13
- .argument("<command>", "Command text, e.g. \"gamemode creative\"")
14
- .action(createRequestAction("chat.command", ({ args }) => ({ command: args[0] })));
38
+ .description("Execute a command. Defaults to auto-routing: prefer player context when a client is available, otherwise use server stdin.")
39
+ .argument("<command>", "Command text, e.g. \"gamemode creative\" (leading slash optional)")
40
+ .option("--via <target>", "Delivery channel: auto (default), server (stdin FIFO), or client (client WS)", "auto")
41
+ .option("--server <name>", "Server instance name when --via server (default: active profile server)")
42
+ .action(wrapCommand(async (context, { args, options, globalOptions }) => {
43
+ const via = options.via ?? "auto";
44
+ const commandText = normalizeChatCommand(args[0]);
45
+ const preferredClient = resolvePreferredClientName(context, globalOptions);
46
+ if (via === "client") {
47
+ return sendClientRequest(context, preferredClient, "chat.command", { command: commandText });
48
+ }
49
+ if (via === "auto") {
50
+ if (preferredClient) {
51
+ return sendClientRequest(context, preferredClient, "chat.command", { command: commandText });
52
+ }
53
+ if (!context.projectName) {
54
+ return sendClientRequest(context, undefined, "chat.command", { command: commandText });
55
+ }
56
+ const serverName = options.server ?? context.activeProfile?.server;
57
+ if (!serverName) {
58
+ throw new MctError({
59
+ code: "INVALID_PARAMS",
60
+ message: "No client context is available, and auto-routing could not resolve a server. Use --client, --server, or run inside a project profile."
61
+ }, 4);
62
+ }
63
+ return executeServerCommand(context, serverName, commandText);
64
+ }
65
+ if (via !== "server") {
66
+ throw new MctError({ code: "INVALID_PARAMS", message: `--via must be \"auto\", \"server\" or \"client\", got: ${via}` }, 4);
67
+ }
68
+ if (!context.projectName) {
69
+ throw new MctError({ code: "NO_PROJECT", message: "--via server requires a project context. Use --via client or run inside an mct project." }, 4);
70
+ }
71
+ const serverName = options.server ?? context.activeProfile?.server;
72
+ if (!serverName) {
73
+ throw new MctError({ code: "INVALID_PARAMS", message: "--via server requires --server <name> or an active profile with a server." }, 4);
74
+ }
75
+ return executeServerCommand(context, serverName, commandText);
76
+ }));
15
77
  command
16
78
  .command("history")
17
79
  .description("Get chat history")
@@ -27,5 +89,9 @@ export function createChatCommand() {
27
89
  .command("last")
28
90
  .description("Get the last chat message")
29
91
  .action(createRequestAction("chat.last", () => ({})));
92
+ command
93
+ .command("clear")
94
+ .description("Clear the cached chat history tracked by the client mod")
95
+ .action(createRequestAction("chat.clear", () => ({})));
30
96
  return command;
31
97
  }
@@ -1,2 +1,4 @@
1
1
  import { Command } from "commander";
2
+ import type { CommandContext } from "../util/context.js";
3
+ export declare function resolveProfileServerAddress(context: Pick<CommandContext, "projectName" | "activeProfile" | "globalState">, explicitServer: string | undefined, loadPort?: (projectName: string, serverName: string) => Promise<number>): Promise<string | undefined>;
2
4
  export declare function createClientCommand(): Command;
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { buildClientSearchResults } from "../download/SearchCommand.js";
3
3
  import { ClientInstanceManager } from "../instance/ClientInstanceManager.js";
4
+ import { ServerInstanceManager } from "../instance/ServerInstanceManager.js";
4
5
  import { MctError } from "../util/errors.js";
5
6
  import { createRequestAction } from "./request-helpers.js";
6
7
  import { wrapCommand } from "../util/command.js";
@@ -12,6 +13,23 @@ import { copyFileIfMissing, downloadFile } from "../download/DownloadUtils.js";
12
13
  import { resolveClientInstanceDir } from "../util/paths.js";
13
14
  import { access, mkdir } from "node:fs/promises";
14
15
  import path from "node:path";
16
+ export async function resolveProfileServerAddress(context, explicitServer, loadPort) {
17
+ if (explicitServer) {
18
+ return explicitServer;
19
+ }
20
+ if (!context.projectName || !context.activeProfile?.server) {
21
+ return undefined;
22
+ }
23
+ try {
24
+ const port = loadPort
25
+ ? await loadPort(context.projectName, context.activeProfile.server)
26
+ : (await new ServerInstanceManager(context.globalState, context.projectName).loadMeta(context.activeProfile.server)).port;
27
+ return `127.0.0.1:${port}`;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
15
33
  export function createClientCommand() {
16
34
  const command = new Command("client").description("Manage Minecraft client instances");
17
35
  command
@@ -125,7 +143,7 @@ export function createClientCommand() {
125
143
  .command("launch")
126
144
  .description("Launch a client instance")
127
145
  .argument("[name]", "Client instance name (default: from active profile)")
128
- .option("--server <address>", "Target server address (e.g. localhost:25565)")
146
+ .option("--server <address>", "Target server address (default: active profile server, e.g. localhost:25565)")
129
147
  .option("--account <account>", "Offline username or account identifier")
130
148
  .option("--ws-port <port>", "WebSocket port override", Number)
131
149
  .option("--headless", "Launch in headless mode")
@@ -136,7 +154,11 @@ export function createClientCommand() {
136
154
  throw new MctError({ code: "INVALID_PARAMS", message: "Client name is required. Specify it as argument or set a profile." }, 4);
137
155
  }
138
156
  const manager = new ClientInstanceManager(context.globalState);
139
- return manager.launch(clientName, options);
157
+ const serverAddress = await resolveProfileServerAddress(context, options.server);
158
+ return manager.launch(clientName, {
159
+ ...options,
160
+ server: serverAddress
161
+ });
140
162
  }));
141
163
  command
142
164
  .command("stop")
@@ -176,5 +198,9 @@ export function createClientCommand() {
176
198
  .action(createRequestAction("client.reconnect", ({ options }) => ({
177
199
  address: options.address
178
200
  })));
201
+ command
202
+ .command("respawn")
203
+ .description("Respawn the player after death (sends C2S respawn packet, bypasses DeathScreen auto-respawn)")
204
+ .action(createRequestAction("client.respawn", () => ({})));
179
205
  return command;
180
206
  }
@@ -102,10 +102,7 @@ export function createInputCommand() {
102
102
  .description("Press a key combination in sequence")
103
103
  .argument("<keys...>", "Key names")
104
104
  .action(createRequestAction("input.key-combo", ({ args }) => ({
105
- keys: String(args[0])
106
- .split(",")
107
- .map((value) => value.trim())
108
- .filter(Boolean)
105
+ keys: args.filter((v) => v !== undefined)
109
106
  })));
110
107
  command
111
108
  .command("type")
@@ -83,7 +83,7 @@ export function createPluginCommand() {
83
83
  .argument("<ids...>", "Plugin IDs to resolve")
84
84
  .action(wrapCommand(async (_context, { args }) => {
85
85
  const manager = new PluginCatalogManager();
86
- const resolved = await manager.resolve(args);
86
+ const resolved = await manager.resolve(args.filter((v) => v !== undefined));
87
87
  return {
88
88
  order: resolved.map((p) => p.id),
89
89
  plugins: resolved
@@ -1,11 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import type { CommandContext, GlobalOptions } from "../util/context.js";
3
3
  export interface RequestPayload<TOptions> {
4
- args: string[];
4
+ args: (string | undefined)[];
5
5
  options: TOptions;
6
6
  globalOptions: GlobalOptions;
7
7
  }
8
8
  export declare function sendClientRequest(context: CommandContext, clientName: string | undefined, action: string, params: Record<string, unknown>, timeoutSeconds?: number): Promise<unknown>;
9
+ export declare function resolvePreferredClientName(context: CommandContext, globalOptions: GlobalOptions): string | undefined;
9
10
  export declare function createRequestAction<TOptions = Record<string, any>>(action: string, buildParams: (payload: RequestPayload<TOptions>) => Record<string, unknown>, timeoutSelector?: (payload: RequestPayload<TOptions>, context: CommandContext) => number | undefined): (this: Command, ...input: unknown[]) => Promise<void>;
10
11
  export declare function parseJson(text: string, fieldName: string): Record<string, unknown>;
11
12
  export declare function parseNumberList(text: string): number[];
@@ -9,10 +9,13 @@ export async function sendClientRequest(context, clientName, action, params, tim
9
9
  const ws = new WebSocketClient(`ws://127.0.0.1:${client.wsPort}`);
10
10
  return ws.send(action, params, timeoutSeconds ?? context.timeout("default"));
11
11
  }
12
+ export function resolvePreferredClientName(context, globalOptions) {
13
+ return globalOptions.client ?? context.activeProfile?.clients[0];
14
+ }
12
15
  export function createRequestAction(action, buildParams, timeoutSelector) {
13
16
  return wrapCommand(async (context, payload) => {
14
17
  const timeout = timeoutSelector?.(payload, context);
15
- return sendClientRequest(context, payload.globalOptions.client, action, buildParams(payload), timeout);
18
+ return sendClientRequest(context, resolvePreferredClientName(context, payload.globalOptions), action, buildParams(payload), timeout);
16
19
  });
17
20
  }
18
21
  export function parseJson(text, fieldName) {
@@ -88,9 +88,15 @@ export function createServerCommand() {
88
88
  .command("status")
89
89
  .description("Show server status")
90
90
  .argument("[name]", "Server instance name (omit to show all in project)")
91
- .action(wrapCommand(async (context, { args }) => {
92
- const project = requireProject(context);
93
- const manager = new ServerInstanceManager(context.globalState, project);
91
+ .option("--all", "Show running servers across all projects")
92
+ .action(wrapCommand(async (context, { args, options }) => {
93
+ if (options.all || (!context.projectName && !args[0])) {
94
+ return ServerInstanceManager.statusAll(context.globalState);
95
+ }
96
+ if (!context.projectName) {
97
+ throw new MctError({ code: "NO_PROJECT", message: "No project context. Omit the name to inspect all running servers, or use --project <name>." }, 4);
98
+ }
99
+ const manager = new ServerInstanceManager(context.globalState, context.projectName);
94
100
  return manager.status(args[0]);
95
101
  }));
96
102
  command
@@ -116,5 +122,47 @@ export function createServerCommand() {
116
122
  const manager = new ServerInstanceManager(context.globalState, project);
117
123
  return manager.waitReady(serverName, options.timeout ?? context.timeout("serverReady"));
118
124
  }));
125
+ command
126
+ .command("exec")
127
+ .description("Send a console command directly to the server stdin FIFO (bypasses client chat)")
128
+ .argument("<command...>", "Command text (leading slash optional, e.g. \"say hi\" or \"op TEST1\")")
129
+ .option("--server <name>", "Server instance name (default: from active profile)")
130
+ .action(wrapCommand(async (context, { args, options }) => {
131
+ const project = requireProject(context);
132
+ const serverName = resolveServerName(context, options.server);
133
+ const manager = new ServerInstanceManager(context.globalState, project);
134
+ return manager.exec(serverName, args.filter((v) => v !== undefined).join(" "));
135
+ }));
136
+ command
137
+ .command("logs")
138
+ .description("Read the server log file (with optional tail/grep/follow)")
139
+ .argument("[name]", "Server instance name (default: from active profile)")
140
+ .option("--tail <n>", "Show only the last N lines", Number)
141
+ .option("--grep <pattern>", "Filter lines by regex")
142
+ .option("--since <lineNumber>", "Skip the first N lines (0-indexed)", Number)
143
+ .option("--follow", "Wait for new log lines (requires --timeout)")
144
+ .option("--timeout <seconds>", "Max seconds to wait when --follow is set", Number)
145
+ .option("--first-match", "With --follow, exit as soon as the first matching line appears")
146
+ .option("--raw-colors", "Preserve ANSI color escape sequences in returned lines")
147
+ .action(wrapCommand(async (context, { args, options }) => {
148
+ const project = requireProject(context);
149
+ const serverName = resolveServerName(context, args[0]);
150
+ const manager = new ServerInstanceManager(context.globalState, project);
151
+ if (options.follow) {
152
+ const timeoutSeconds = options.timeout ?? 30;
153
+ return manager.followLogs(serverName, {
154
+ grep: options.grep,
155
+ timeoutSeconds,
156
+ firstMatchOnly: Boolean(options.firstMatch),
157
+ rawColors: Boolean(options.rawColors)
158
+ });
159
+ }
160
+ return manager.readLogs(serverName, {
161
+ tail: options.tail,
162
+ grep: options.grep,
163
+ since: options.since,
164
+ rawColors: Boolean(options.rawColors)
165
+ });
166
+ }));
119
167
  return command;
120
168
  }
@@ -244,7 +244,7 @@ export class ClientInstanceManager {
244
244
  });
245
245
  throw new MctError({
246
246
  code: "TIMEOUT",
247
- message: `Timed out after ${timeoutSeconds}s waiting for client ${clientName} to join a world (${wsUrl}). ${this.formatDiagnostics(diag)} Tip: try \`mct client reconnect --address <server>\` or \`mct client launch --force\`.`,
247
+ message: `Timed out after ${timeoutSeconds}s waiting for client ${clientName} to join a world (${wsUrl}). ${this.formatDiagnostics(diag)} Tip: if the client is still at the main menu, run \`mct client reconnect --address <server>\` or relaunch with \`mct client launch --server <address>\` (inside a project, plain \`mct client launch\` uses the active profile server).`,
248
248
  details: diag
249
249
  }, 2);
250
250
  }
@@ -1,5 +1,7 @@
1
1
  import type { GlobalStateStore } from "../util/global-state.js";
2
2
  import type { ServerInstanceMeta, ServerRuntimeEntry, ServerType } from "../util/instance-types.js";
3
+ export declare function stripAnsiCodes(text: string): string;
4
+ export declare function ensureServerPortProperty(instanceDir: string, port: number): Promise<void>;
3
5
  export interface CreateServerOptions {
4
6
  name: string;
5
7
  project: string;
@@ -60,11 +62,44 @@ export declare class ServerInstanceManager {
60
62
  stdinPipe?: string;
61
63
  running: boolean;
62
64
  }>;
65
+ static statusAll(globalState: GlobalStateStore): Promise<{
66
+ [key: string]: unknown;
67
+ running: boolean;
68
+ }[]>;
63
69
  waitReady(serverName: string, timeoutSeconds: number): Promise<{
64
70
  reachable: boolean;
65
71
  host: string;
66
72
  port: number;
67
73
  }>;
74
+ exec(serverName: string, command: string): Promise<{
75
+ sent: boolean;
76
+ command: string;
77
+ stdinPipe: string;
78
+ }>;
79
+ readLogs(serverName: string, options?: {
80
+ tail?: number;
81
+ grep?: string;
82
+ since?: number;
83
+ rawColors?: boolean;
84
+ }): Promise<{
85
+ logPath: string;
86
+ totalLines: number;
87
+ returnedLines: number;
88
+ lines: string[];
89
+ }>;
90
+ followLogs(serverName: string, options: {
91
+ grep?: string;
92
+ timeoutSeconds: number;
93
+ firstMatchOnly?: boolean;
94
+ rawColors?: boolean;
95
+ }): Promise<{
96
+ logPath: string;
97
+ matched: boolean;
98
+ matches: string[];
99
+ timedOut: boolean;
100
+ }>;
101
+ private requireRuntimeEntry;
102
+ private requireRunning;
68
103
  list(): Promise<ServerInstanceMeta[]>;
69
104
  static listAll(globalState: GlobalStateStore): Promise<ServerInstanceMeta[]>;
70
105
  deploy(serverName: string, jarPaths: string[], cwd: string): Promise<string[]>;
@@ -1,5 +1,5 @@
1
- import { copyFile, mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
2
- import { mkdirSync, openSync } from "node:fs";
1
+ import { copyFile, mkdir, open as fsOpen, readdir, readFile, stat, writeFile, unlink } from "node:fs/promises";
2
+ import { openSync, writeSync, closeSync, mkdirSync } from "node:fs";
3
3
  import { spawn, execSync } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { resolveMctHome, resolveProjectDir, resolveServerInstanceDir } from "../util/paths.js";
@@ -8,6 +8,36 @@ import { waitForTcpPort } 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
+ const ANSI_ESCAPE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
12
+ export function stripAnsiCodes(text) {
13
+ return text.replace(ANSI_ESCAPE_PATTERN, "");
14
+ }
15
+ export async function ensureServerPortProperty(instanceDir, port) {
16
+ const filePath = path.join(instanceDir, "server.properties");
17
+ let lines = [];
18
+ try {
19
+ const raw = await readFile(filePath, "utf8");
20
+ lines = raw.split(/\r?\n/);
21
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
22
+ lines.pop();
23
+ }
24
+ }
25
+ catch {
26
+ // initialize from scratch
27
+ }
28
+ let updated = false;
29
+ lines = lines.map((line) => {
30
+ if (/^\s*server-port\s*=/.test(line)) {
31
+ updated = true;
32
+ return `server-port=${port}`;
33
+ }
34
+ return line;
35
+ });
36
+ if (!updated) {
37
+ lines.push(`server-port=${port}`);
38
+ }
39
+ await writeFile(filePath, `${lines.join("\n")}\n`, "utf8");
40
+ }
11
41
  export class ServerInstanceManager {
12
42
  globalState;
13
43
  project;
@@ -28,6 +58,7 @@ export class ServerInstanceManager {
28
58
  await writeFile(path.join(instanceDir, "eula.txt"), "eula=true\n", "utf8");
29
59
  }
30
60
  await mkdir(path.join(instanceDir, "plugins"), { recursive: true });
61
+ await ensureServerPortProperty(instanceDir, port);
31
62
  const meta = {
32
63
  name: options.name,
33
64
  project: options.project,
@@ -56,6 +87,7 @@ export class ServerInstanceManager {
56
87
  if (options.eula) {
57
88
  await writeFile(path.join(instanceDir, "eula.txt"), "eula=true\n", "utf8");
58
89
  }
90
+ await ensureServerPortProperty(instanceDir, meta.port);
59
91
  const mctHome = resolveMctHome();
60
92
  const logsDir = path.join(mctHome, "logs");
61
93
  const stateDir = path.join(mctHome, "state");
@@ -154,6 +186,19 @@ export class ServerInstanceManager {
154
186
  await this.globalState.writeServerState(state);
155
187
  return results;
156
188
  }
189
+ static async statusAll(globalState) {
190
+ const state = await globalState.readServerState();
191
+ const results = [];
192
+ for (const [key, entry] of Object.entries(state.servers)) {
193
+ const running = isProcessRunning(entry.pid);
194
+ if (!running) {
195
+ delete state.servers[key];
196
+ }
197
+ results.push({ running, ...entry });
198
+ }
199
+ await globalState.writeServerState(state);
200
+ return results;
201
+ }
157
202
  async waitReady(serverName, timeoutSeconds) {
158
203
  const stateKey = `${this.project}/${serverName}`;
159
204
  const state = await this.globalState.readServerState();
@@ -163,6 +208,143 @@ export class ServerInstanceManager {
163
208
  }
164
209
  return waitForTcpPort("127.0.0.1", entry.port, timeoutSeconds);
165
210
  }
211
+ async exec(serverName, command) {
212
+ const entry = await this.requireRunning(serverName);
213
+ if (!entry.stdinPipe) {
214
+ throw new MctError({ code: "SERVER_STDIN_UNAVAILABLE", message: `Server ${this.project}/${serverName} has no stdin FIFO (detached mode?)` }, 5);
215
+ }
216
+ const trimmed = command.trim();
217
+ if (!trimmed) {
218
+ throw new MctError({ code: "INVALID_PARAMS", message: "Command is required" }, 4);
219
+ }
220
+ const line = `${trimmed.replace(/^\//, "")}\n`;
221
+ // O_NONBLOCK write: bash wrapper holds FIFO fd in rw mode so this returns immediately.
222
+ let fd;
223
+ try {
224
+ fd = openSync(entry.stdinPipe, "w");
225
+ }
226
+ catch (error) {
227
+ throw new MctError({ code: "SERVER_STDIN_OPEN_FAILED", message: `Failed to open stdin FIFO: ${error.message}`, details: { stdinPipe: entry.stdinPipe } }, 5);
228
+ }
229
+ try {
230
+ writeSync(fd, line);
231
+ }
232
+ finally {
233
+ closeSync(fd);
234
+ }
235
+ return { sent: true, command: trimmed, stdinPipe: entry.stdinPipe };
236
+ }
237
+ async readLogs(serverName, options = {}) {
238
+ const entry = await this.requireRuntimeEntry(serverName);
239
+ const logPath = entry.logPath;
240
+ const raw = await readFile(logPath, "utf8").catch((error) => {
241
+ if (error.code === "ENOENT")
242
+ return "";
243
+ throw error;
244
+ });
245
+ let lines = raw.split("\n");
246
+ if (lines.length > 0 && lines[lines.length - 1] === "")
247
+ lines = lines.slice(0, -1);
248
+ const total = lines.length;
249
+ if (!options.rawColors) {
250
+ lines = lines.map((line) => stripAnsiCodes(line));
251
+ }
252
+ if (options.since !== undefined && options.since > 0) {
253
+ lines = lines.slice(Math.max(0, options.since));
254
+ }
255
+ if (options.grep) {
256
+ const re = new RegExp(options.grep);
257
+ lines = lines.filter((line) => re.test(line));
258
+ }
259
+ if (options.tail !== undefined && options.tail > 0 && lines.length > options.tail) {
260
+ lines = lines.slice(lines.length - options.tail);
261
+ }
262
+ return { logPath, totalLines: total, returnedLines: lines.length, lines };
263
+ }
264
+ async followLogs(serverName, options) {
265
+ const entry = await this.requireRuntimeEntry(serverName);
266
+ const logPath = entry.logPath;
267
+ const re = options.grep ? new RegExp(options.grep) : null;
268
+ let offset = 0;
269
+ try {
270
+ offset = (await stat(logPath)).size;
271
+ }
272
+ catch { /* file may not exist yet */ }
273
+ const matches = [];
274
+ let buffer = "";
275
+ let done = false;
276
+ return await new Promise((resolve) => {
277
+ let timer;
278
+ let poll;
279
+ const finish = (timedOut) => {
280
+ if (done)
281
+ return;
282
+ done = true;
283
+ if (poll)
284
+ clearInterval(poll);
285
+ if (timer)
286
+ clearTimeout(timer);
287
+ resolve({ logPath, matched: matches.length > 0, matches, timedOut });
288
+ };
289
+ const drain = async () => {
290
+ if (done)
291
+ return;
292
+ let currentSize;
293
+ try {
294
+ currentSize = (await stat(logPath)).size;
295
+ }
296
+ catch {
297
+ return;
298
+ }
299
+ if (currentSize < offset) {
300
+ offset = 0;
301
+ buffer = "";
302
+ } // rotation / truncate
303
+ if (currentSize === offset)
304
+ return;
305
+ // Read raw bytes and decode — stat().size is bytes, not UTF-16 chars.
306
+ const fh = await fsOpen(logPath, "r");
307
+ try {
308
+ const length = currentSize - offset;
309
+ const buf = Buffer.allocUnsafe(length);
310
+ await fh.read(buf, 0, length, offset);
311
+ offset = currentSize;
312
+ buffer += buf.toString("utf8");
313
+ }
314
+ finally {
315
+ await fh.close();
316
+ }
317
+ const parts = buffer.split("\n");
318
+ buffer = parts.pop() ?? "";
319
+ for (const line of parts) {
320
+ const rendered = options.rawColors ? line : stripAnsiCodes(line);
321
+ if (!re || re.test(rendered)) {
322
+ matches.push(rendered);
323
+ if (options.firstMatchOnly)
324
+ return finish(false);
325
+ }
326
+ }
327
+ };
328
+ timer = setTimeout(() => finish(true), options.timeoutSeconds * 1000);
329
+ poll = setInterval(() => { void drain(); }, 300);
330
+ });
331
+ }
332
+ async requireRuntimeEntry(serverName) {
333
+ const stateKey = `${this.project}/${serverName}`;
334
+ const state = await this.globalState.readServerState();
335
+ const entry = state.servers[stateKey];
336
+ if (!entry) {
337
+ throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${stateKey} is not running` }, 5);
338
+ }
339
+ return entry;
340
+ }
341
+ async requireRunning(serverName) {
342
+ const entry = await this.requireRuntimeEntry(serverName);
343
+ if (!isProcessRunning(entry.pid)) {
344
+ throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${this.project}/${serverName} PID ${entry.pid} is not alive` }, 5);
345
+ }
346
+ return entry;
347
+ }
166
348
  async list() {
167
349
  const projectDir = resolveProjectDir(this.project);
168
350
  try {
@@ -1,7 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { createCommandContext, type GlobalOptions } from "./context.js";
3
3
  export type CommandAction<TOptions = Record<string, unknown>> = (context: Awaited<ReturnType<typeof createCommandContext>>, payload: {
4
- args: string[];
4
+ args: (string | undefined)[];
5
5
  options: TOptions;
6
6
  command: Command;
7
7
  globalOptions: GlobalOptions;
@@ -13,7 +13,13 @@ export function wrapCommand(action) {
13
13
  return async function wrappedCommand(...input) {
14
14
  const command = input.at(-1);
15
15
  const options = input.at(-2);
16
- const args = input.slice(0, -2).map((value) => String(value));
16
+ const args = input.slice(0, -2).flatMap((value) => {
17
+ if (Array.isArray(value))
18
+ return value.map((v) => String(v));
19
+ if (value === undefined || value === null)
20
+ return [undefined];
21
+ return [String(value)];
22
+ });
17
23
  const globalOptions = command.optsWithGlobals();
18
24
  try {
19
25
  const context = await createCommandContext(globalOptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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": {