@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.
- package/dist/commands/chat.js +72 -6
- package/dist/commands/client.d.ts +2 -0
- package/dist/commands/client.js +28 -2
- package/dist/commands/input.js +1 -4
- package/dist/commands/plugin.js +1 -1
- package/dist/commands/request-helpers.d.ts +2 -1
- package/dist/commands/request-helpers.js +4 -1
- package/dist/commands/server.js +51 -3
- package/dist/instance/ClientInstanceManager.js +1 -1
- package/dist/instance/ServerInstanceManager.d.ts +35 -0
- package/dist/instance/ServerInstanceManager.js +184 -2
- package/dist/util/command.d.ts +1 -1
- package/dist/util/command.js +7 -1
- package/package.json +1 -1
package/dist/commands/chat.js
CHANGED
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import {
|
|
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
|
-
.
|
|
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
|
|
13
|
-
.argument("<command>", "Command text, e.g. \"gamemode creative\"")
|
|
14
|
-
.
|
|
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;
|
package/dist/commands/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/commands/input.js
CHANGED
|
@@ -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:
|
|
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")
|
package/dist/commands/plugin.js
CHANGED
|
@@ -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
|
|
18
|
+
return sendClientRequest(context, resolvePreferredClientName(context, payload.globalOptions), action, buildParams(payload), timeout);
|
|
16
19
|
});
|
|
17
20
|
}
|
|
18
21
|
export function parseJson(text, fieldName) {
|
package/dist/commands/server.js
CHANGED
|
@@ -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
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
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 {
|
|
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 {
|
package/dist/util/command.d.ts
CHANGED
|
@@ -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;
|
package/dist/util/command.js
CHANGED
|
@@ -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).
|
|
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);
|