@kzheart_/mc-pilot 0.5.0 → 0.6.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,5 +1,8 @@
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, sendClientRequest, withTransportTimeoutBuffer } from "./request-helpers.js";
3
6
  export function createChatCommand() {
4
7
  const command = new Command("chat").description("Chat and server commands");
5
8
  command
@@ -9,9 +12,28 @@ export function createChatCommand() {
9
12
  .action(createRequestAction("chat.send", ({ args }) => ({ message: args[0] })));
10
13
  command
11
14
  .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] })));
15
+ .description("Execute a server command. Defaults to server stdin FIFO (reliable); use --via client to route through the client's chat.")
16
+ .argument("<command>", "Command text, e.g. \"gamemode creative\" (leading slash optional)")
17
+ .option("--via <target>", "Delivery channel: server (stdin FIFO, default) or client (client WS, may fail if chat disabled)", "server")
18
+ .option("--server <name>", "Server instance name when --via server (default: active profile server)")
19
+ .action(wrapCommand(async (context, { args, options, globalOptions }) => {
20
+ const via = options.via ?? "server";
21
+ if (via === "client") {
22
+ return sendClientRequest(context, globalOptions.client, "chat.command", { command: args[0] });
23
+ }
24
+ if (via !== "server") {
25
+ throw new MctError({ code: "INVALID_PARAMS", message: `--via must be \"server\" or \"client\", got: ${via}` }, 4);
26
+ }
27
+ if (!context.projectName) {
28
+ throw new MctError({ code: "NO_PROJECT", message: "--via server requires a project context. Use --via client or run inside an mct project." }, 4);
29
+ }
30
+ const serverName = options.server ?? context.activeProfile?.server;
31
+ if (!serverName) {
32
+ throw new MctError({ code: "INVALID_PARAMS", message: "--via server requires --server <name> or an active profile with a server." }, 4);
33
+ }
34
+ const manager = new ServerInstanceManager(context.globalState, context.projectName);
35
+ return manager.exec(serverName, args[0]);
36
+ }));
15
37
  command
16
38
  .command("history")
17
39
  .description("Get chat history")
@@ -176,5 +176,9 @@ export function createClientCommand() {
176
176
  .action(createRequestAction("client.reconnect", ({ options }) => ({
177
177
  address: options.address
178
178
  })));
179
+ command
180
+ .command("respawn")
181
+ .description("Respawn the player after death (sends C2S respawn packet, bypasses DeathScreen auto-respawn)")
182
+ .action(createRequestAction("client.respawn", () => ({})));
179
183
  return command;
180
184
  }
@@ -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,7 +1,7 @@
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
  }
@@ -116,5 +116,44 @@ export function createServerCommand() {
116
116
  const manager = new ServerInstanceManager(context.globalState, project);
117
117
  return manager.waitReady(serverName, options.timeout ?? context.timeout("serverReady"));
118
118
  }));
119
+ command
120
+ .command("exec")
121
+ .description("Send a console command directly to the server stdin FIFO (bypasses client chat)")
122
+ .argument("<command...>", "Command text (leading slash optional, e.g. \"say hi\" or \"op TEST1\")")
123
+ .option("--server <name>", "Server instance name (default: from active profile)")
124
+ .action(wrapCommand(async (context, { args, options }) => {
125
+ const project = requireProject(context);
126
+ const serverName = resolveServerName(context, options.server);
127
+ const manager = new ServerInstanceManager(context.globalState, project);
128
+ return manager.exec(serverName, args.filter((v) => v !== undefined).join(" "));
129
+ }));
130
+ command
131
+ .command("logs")
132
+ .description("Read the server log file (with optional tail/grep/follow)")
133
+ .argument("[name]", "Server instance name (default: from active profile)")
134
+ .option("--tail <n>", "Show only the last N lines", Number)
135
+ .option("--grep <pattern>", "Filter lines by regex")
136
+ .option("--since <lineNumber>", "Skip the first N lines (0-indexed)", Number)
137
+ .option("--follow", "Wait for new log lines (requires --timeout)")
138
+ .option("--timeout <seconds>", "Max seconds to wait when --follow is set", Number)
139
+ .option("--first-match", "With --follow, exit as soon as the first matching line appears")
140
+ .action(wrapCommand(async (context, { args, options }) => {
141
+ const project = requireProject(context);
142
+ const serverName = resolveServerName(context, args[0]);
143
+ const manager = new ServerInstanceManager(context.globalState, project);
144
+ if (options.follow) {
145
+ const timeoutSeconds = options.timeout ?? 30;
146
+ return manager.followLogs(serverName, {
147
+ grep: options.grep,
148
+ timeoutSeconds,
149
+ firstMatchOnly: Boolean(options.firstMatch)
150
+ });
151
+ }
152
+ return manager.readLogs(serverName, {
153
+ tail: options.tail,
154
+ grep: options.grep,
155
+ since: options.since
156
+ });
157
+ }));
119
158
  return command;
120
159
  }
@@ -65,6 +65,33 @@ export declare class ServerInstanceManager {
65
65
  host: string;
66
66
  port: number;
67
67
  }>;
68
+ exec(serverName: string, command: string): Promise<{
69
+ sent: boolean;
70
+ command: string;
71
+ stdinPipe: string;
72
+ }>;
73
+ readLogs(serverName: string, options?: {
74
+ tail?: number;
75
+ grep?: string;
76
+ since?: number;
77
+ }): Promise<{
78
+ logPath: string;
79
+ totalLines: number;
80
+ returnedLines: number;
81
+ lines: string[];
82
+ }>;
83
+ followLogs(serverName: string, options: {
84
+ grep?: string;
85
+ timeoutSeconds: number;
86
+ firstMatchOnly?: boolean;
87
+ }): Promise<{
88
+ logPath: string;
89
+ matched: boolean;
90
+ matches: string[];
91
+ timedOut: boolean;
92
+ }>;
93
+ private requireRuntimeEntry;
94
+ private requireRunning;
68
95
  list(): Promise<ServerInstanceMeta[]>;
69
96
  static listAll(globalState: GlobalStateStore): Promise<ServerInstanceMeta[]>;
70
97
  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";
@@ -163,6 +163,139 @@ export class ServerInstanceManager {
163
163
  }
164
164
  return waitForTcpPort("127.0.0.1", entry.port, timeoutSeconds);
165
165
  }
166
+ async exec(serverName, command) {
167
+ const entry = await this.requireRunning(serverName);
168
+ if (!entry.stdinPipe) {
169
+ throw new MctError({ code: "SERVER_STDIN_UNAVAILABLE", message: `Server ${this.project}/${serverName} has no stdin FIFO (detached mode?)` }, 5);
170
+ }
171
+ const trimmed = command.trim();
172
+ if (!trimmed) {
173
+ throw new MctError({ code: "INVALID_PARAMS", message: "Command is required" }, 4);
174
+ }
175
+ const line = `${trimmed.replace(/^\//, "")}\n`;
176
+ // O_NONBLOCK write: bash wrapper holds FIFO fd in rw mode so this returns immediately.
177
+ let fd;
178
+ try {
179
+ fd = openSync(entry.stdinPipe, "w");
180
+ }
181
+ catch (error) {
182
+ throw new MctError({ code: "SERVER_STDIN_OPEN_FAILED", message: `Failed to open stdin FIFO: ${error.message}`, details: { stdinPipe: entry.stdinPipe } }, 5);
183
+ }
184
+ try {
185
+ writeSync(fd, line);
186
+ }
187
+ finally {
188
+ closeSync(fd);
189
+ }
190
+ return { sent: true, command: trimmed, stdinPipe: entry.stdinPipe };
191
+ }
192
+ async readLogs(serverName, options = {}) {
193
+ const entry = await this.requireRuntimeEntry(serverName);
194
+ const logPath = entry.logPath;
195
+ const raw = await readFile(logPath, "utf8").catch((error) => {
196
+ if (error.code === "ENOENT")
197
+ return "";
198
+ throw error;
199
+ });
200
+ let lines = raw.split("\n");
201
+ if (lines.length > 0 && lines[lines.length - 1] === "")
202
+ lines = lines.slice(0, -1);
203
+ const total = lines.length;
204
+ if (options.since !== undefined && options.since > 0) {
205
+ lines = lines.slice(Math.max(0, options.since));
206
+ }
207
+ if (options.grep) {
208
+ const re = new RegExp(options.grep);
209
+ lines = lines.filter((line) => re.test(line));
210
+ }
211
+ if (options.tail !== undefined && options.tail > 0 && lines.length > options.tail) {
212
+ lines = lines.slice(lines.length - options.tail);
213
+ }
214
+ return { logPath, totalLines: total, returnedLines: lines.length, lines };
215
+ }
216
+ async followLogs(serverName, options) {
217
+ const entry = await this.requireRuntimeEntry(serverName);
218
+ const logPath = entry.logPath;
219
+ const re = options.grep ? new RegExp(options.grep) : null;
220
+ let offset = 0;
221
+ try {
222
+ offset = (await stat(logPath)).size;
223
+ }
224
+ catch { /* file may not exist yet */ }
225
+ const matches = [];
226
+ let buffer = "";
227
+ let done = false;
228
+ return await new Promise((resolve) => {
229
+ let timer;
230
+ let poll;
231
+ const finish = (timedOut) => {
232
+ if (done)
233
+ return;
234
+ done = true;
235
+ if (poll)
236
+ clearInterval(poll);
237
+ if (timer)
238
+ clearTimeout(timer);
239
+ resolve({ logPath, matched: matches.length > 0, matches, timedOut });
240
+ };
241
+ const drain = async () => {
242
+ if (done)
243
+ return;
244
+ let currentSize;
245
+ try {
246
+ currentSize = (await stat(logPath)).size;
247
+ }
248
+ catch {
249
+ return;
250
+ }
251
+ if (currentSize < offset) {
252
+ offset = 0;
253
+ buffer = "";
254
+ } // rotation / truncate
255
+ if (currentSize === offset)
256
+ return;
257
+ // Read raw bytes and decode — stat().size is bytes, not UTF-16 chars.
258
+ const fh = await fsOpen(logPath, "r");
259
+ try {
260
+ const length = currentSize - offset;
261
+ const buf = Buffer.allocUnsafe(length);
262
+ await fh.read(buf, 0, length, offset);
263
+ offset = currentSize;
264
+ buffer += buf.toString("utf8");
265
+ }
266
+ finally {
267
+ await fh.close();
268
+ }
269
+ const parts = buffer.split("\n");
270
+ buffer = parts.pop() ?? "";
271
+ for (const line of parts) {
272
+ if (!re || re.test(line)) {
273
+ matches.push(line);
274
+ if (options.firstMatchOnly)
275
+ return finish(false);
276
+ }
277
+ }
278
+ };
279
+ timer = setTimeout(() => finish(true), options.timeoutSeconds * 1000);
280
+ poll = setInterval(() => { void drain(); }, 300);
281
+ });
282
+ }
283
+ async requireRuntimeEntry(serverName) {
284
+ const stateKey = `${this.project}/${serverName}`;
285
+ const state = await this.globalState.readServerState();
286
+ const entry = state.servers[stateKey];
287
+ if (!entry) {
288
+ throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${stateKey} is not running` }, 5);
289
+ }
290
+ return entry;
291
+ }
292
+ async requireRunning(serverName) {
293
+ const entry = await this.requireRuntimeEntry(serverName);
294
+ if (!isProcessRunning(entry.pid)) {
295
+ throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${this.project}/${serverName} PID ${entry.pid} is not alive` }, 5);
296
+ }
297
+ return entry;
298
+ }
166
299
  async list() {
167
300
  const projectDir = resolveProjectDir(this.project);
168
301
  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.6.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": {