@mavonengine/cli 0.0.1 → 0.0.3
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/README.md +289 -10
- package/dist/chunk-NIBSLCCZ.js +91 -0
- package/dist/chunk-XPKCYSMN.js +44 -0
- package/dist/chunk-ZEMFSSJP.js +87 -0
- package/dist/docs-O2V2WSUG.js +13 -0
- package/dist/index.js +2440 -359
- package/dist/server-NNL4IMYV.js +925 -0
- package/package.json +7 -5
package/dist/index.js
CHANGED
|
@@ -1,174 +1,257 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
PLUGIN_REGISTRY_ENV,
|
|
4
|
+
PLUGIN_REGISTRY_URL,
|
|
5
|
+
SUPPORTED_REGISTRY_VERSION,
|
|
6
|
+
fetchPluginRegistry,
|
|
7
|
+
notInProject
|
|
8
|
+
} from "./chunk-ZEMFSSJP.js";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_DOCS_URL,
|
|
11
|
+
MCP_CONFIG_FILE,
|
|
12
|
+
version,
|
|
13
|
+
writeMcpConfig
|
|
14
|
+
} from "./chunk-XPKCYSMN.js";
|
|
2
15
|
|
|
3
16
|
// src/index.ts
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
// package.json
|
|
7
|
-
var version = "0.0.1";
|
|
17
|
+
import process11 from "process";
|
|
8
18
|
|
|
9
|
-
// src/commands/
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
19
|
+
// src/commands/build.ts
|
|
20
|
+
import { spawn as spawn4 } from "child_process";
|
|
21
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
22
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
23
|
+
import process7 from "process";
|
|
24
|
+
import { parseArgs as parseArgs4 } from "util";
|
|
14
25
|
|
|
15
|
-
//
|
|
16
|
-
import {
|
|
17
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
18
|
-
import { tmpdir } from "os";
|
|
26
|
+
// ../core/dist/Plugins/ProjectConfig.js
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
19
28
|
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
var BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
27
|
-
function formatBytes(bytes, decimals = 1) {
|
|
28
|
-
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
29
|
-
return "0 B";
|
|
30
|
-
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
31
|
-
const value = bytes / 1024 ** exponent;
|
|
32
|
-
return `${exponent === 0 ? value : value.toFixed(decimals)} ${BYTE_UNITS[exponent]}`;
|
|
29
|
+
var CONFIG_FILE = "mavonengine.json";
|
|
30
|
+
var TARGETS = ["client", "server", "both"];
|
|
31
|
+
var PluginConfigError = class extends Error {
|
|
32
|
+
};
|
|
33
|
+
function configPath(root) {
|
|
34
|
+
return join(root, CONFIG_FILE);
|
|
33
35
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
+
function findConfigRoot(start) {
|
|
37
|
+
for (let dir = resolve(start); ; dir = dirname(dir)) {
|
|
38
|
+
if (existsSync(configPath(dir)))
|
|
39
|
+
return dir;
|
|
40
|
+
if (dirname(dir) === dir)
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
|
-
function
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
function readConfig(root) {
|
|
45
|
+
const path = configPath(root);
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return { plugins: {} };
|
|
48
|
+
let config;
|
|
49
|
+
try {
|
|
50
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new PluginConfigError(`${path} is not valid JSON: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
55
|
+
throw new PluginConfigError(`${path} must contain a JSON object`);
|
|
56
|
+
const result = config;
|
|
57
|
+
if (result.plugins === void 0)
|
|
58
|
+
result.plugins = {};
|
|
59
|
+
else if (!result.plugins || typeof result.plugins !== "object" || Array.isArray(result.plugins))
|
|
60
|
+
throw new PluginConfigError(`"plugins" in ${path} must be an object`);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function writeConfig(root, config) {
|
|
64
|
+
writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}
|
|
65
|
+
`);
|
|
50
66
|
}
|
|
51
|
-
function
|
|
52
|
-
|
|
67
|
+
function updateConfig(root, change) {
|
|
68
|
+
const config = readConfig(root);
|
|
69
|
+
if (!change(config))
|
|
70
|
+
return false;
|
|
71
|
+
writeConfig(root, config);
|
|
72
|
+
return true;
|
|
53
73
|
}
|
|
54
|
-
function
|
|
55
|
-
return
|
|
74
|
+
function addPluginToConfig(root, pkg) {
|
|
75
|
+
return updateConfig(root, (config) => {
|
|
76
|
+
const existing = config.plugins[pkg];
|
|
77
|
+
if (existing && existing.enabled !== false)
|
|
78
|
+
return false;
|
|
79
|
+
config.plugins[pkg] = existing ? { ...existing, enabled: true } : { enabled: true };
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
56
82
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
function findPackageDir(root, pkg) {
|
|
84
|
+
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
85
|
+
const candidate = join(dir, "node_modules", pkg);
|
|
86
|
+
if (existsSync(join(candidate, "package.json")))
|
|
87
|
+
return candidate;
|
|
88
|
+
if (dirname(dir) === dir)
|
|
89
|
+
return void 0;
|
|
90
|
+
}
|
|
64
91
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
report.step(`Downloading the template\u2026 ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ""}`);
|
|
75
|
-
callback(null, chunk);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
const dest = join(tmpdir(), `mavonengine-create-${process.pid}-${Date.now()}.zip`);
|
|
79
|
-
try {
|
|
80
|
-
await pipeline(Readable.fromWeb(response.body), progress, createWriteStream(dest), { signal });
|
|
81
|
-
} catch (err) {
|
|
82
|
-
rmSync(dest, { force: true });
|
|
83
|
-
throw err;
|
|
92
|
+
function validateManifest(pkg, value) {
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
throw new PluginConfigError(`${pkg}: "mavonengine" in package.json must be an object`);
|
|
95
|
+
const manifest = value;
|
|
96
|
+
if (!TARGETS.includes(manifest.target))
|
|
97
|
+
throw new PluginConfigError(`${pkg}: "mavonengine.target" must be one of ${TARGETS.join(", ")}`);
|
|
98
|
+
for (const side of ["client", "server"]) {
|
|
99
|
+
if (runsOn(manifest.target, side) && typeof manifest[side] !== "string")
|
|
100
|
+
throw new PluginConfigError(`${pkg}: "mavonengine.${side}" is required for target "${manifest.target}"`);
|
|
84
101
|
}
|
|
85
|
-
return
|
|
102
|
+
return manifest;
|
|
86
103
|
}
|
|
87
|
-
function
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
function readPluginManifest(root, pkg) {
|
|
105
|
+
const dir = findPackageDir(root, pkg);
|
|
106
|
+
if (!dir)
|
|
107
|
+
return void 0;
|
|
108
|
+
const packageJson = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
109
|
+
if (packageJson.mavonengine === void 0)
|
|
110
|
+
return void 0;
|
|
111
|
+
return { package: pkg, version: packageJson.version, dir, manifest: validateManifest(pkg, packageJson.mavonengine) };
|
|
112
|
+
}
|
|
113
|
+
function runsOn(target, side) {
|
|
114
|
+
return target === "both" || target === side;
|
|
115
|
+
}
|
|
116
|
+
function resolveInside(base, path) {
|
|
117
|
+
const full = resolve(base, path);
|
|
118
|
+
const inside = relative(base, full);
|
|
119
|
+
return inside && !inside.startsWith("..") && !isAbsolute(inside) ? full : void 0;
|
|
120
|
+
}
|
|
121
|
+
function assetsDir(plugin) {
|
|
122
|
+
return resolve(plugin.dir, plugin.manifest.assets?.dir ?? "assets");
|
|
123
|
+
}
|
|
124
|
+
function enabledPlugins(root, side) {
|
|
125
|
+
const plugins = [];
|
|
126
|
+
const warnings = [];
|
|
127
|
+
for (const [pkg, entry] of Object.entries(readConfig(root).plugins)) {
|
|
128
|
+
if (entry?.enabled === false)
|
|
101
129
|
continue;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
130
|
+
let installed;
|
|
131
|
+
try {
|
|
132
|
+
installed = readPluginManifest(root, pkg);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
warnings.push(err.message);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!installed) {
|
|
138
|
+
warnings.push(`${pkg} is listed in ${CONFIG_FILE} but is not installed or is not a MavonEngine plugin`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!runsOn(installed.manifest.target, side))
|
|
142
|
+
continue;
|
|
143
|
+
const entryPath = resolveInside(installed.dir, installed.manifest[side]);
|
|
144
|
+
if (!entryPath || !existsSync(entryPath)) {
|
|
145
|
+
warnings.push(`${pkg}: ${side} entry ${installed.manifest[side]} not found, is the plugin built?`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
plugins.push({
|
|
149
|
+
...installed,
|
|
150
|
+
options: entry?.options ?? {},
|
|
151
|
+
prefabs: Array.isArray(entry?.prefabs) ? entry.prefabs : [],
|
|
152
|
+
entry: entryPath
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return { plugins, warnings };
|
|
156
|
+
}
|
|
157
|
+
function shippedAssetPaths(manifest, usedPrefabs) {
|
|
158
|
+
const assets = manifest.assets;
|
|
159
|
+
const paths = [...assets?.shared ?? []];
|
|
160
|
+
for (const prefab of usedPrefabs)
|
|
161
|
+
paths.push(...assets?.prefabs?.[prefab] ?? []);
|
|
162
|
+
return [...new Set(paths)];
|
|
163
|
+
}
|
|
164
|
+
function releaseAssets(root) {
|
|
165
|
+
const { plugins, warnings } = enabledPlugins(root, "client");
|
|
166
|
+
const assets = [];
|
|
167
|
+
for (const plugin of plugins) {
|
|
168
|
+
const base = assetsDir(plugin);
|
|
169
|
+
for (const path of shippedAssetPaths(plugin.manifest, plugin.prefabs)) {
|
|
170
|
+
const from = resolveInside(base, path);
|
|
171
|
+
if (!from) {
|
|
172
|
+
warnings.push(`${plugin.package}: asset ${path} points outside its assets directory`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!existsSync(from)) {
|
|
176
|
+
warnings.push(`${plugin.package}: asset ${path} not found`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
assets.push({ package: plugin.package, from, to: `plugins/${plugin.package}/${path}`, path });
|
|
112
180
|
}
|
|
113
181
|
}
|
|
114
|
-
return
|
|
182
|
+
return { assets, warnings };
|
|
115
183
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
184
|
+
|
|
185
|
+
// src/commands/dashboard.ts
|
|
186
|
+
import process3 from "process";
|
|
187
|
+
import { parseArgs } from "util";
|
|
188
|
+
|
|
189
|
+
// src/browser.ts
|
|
190
|
+
import { spawn } from "child_process";
|
|
191
|
+
import process from "process";
|
|
192
|
+
var AMPERSAND_RE = /&/g;
|
|
193
|
+
function openCommand(url, platform = process.platform) {
|
|
194
|
+
if (platform === "darwin")
|
|
195
|
+
return ["open", [url]];
|
|
196
|
+
if (platform === "win32")
|
|
197
|
+
return ["cmd", ["/c", "start", '""', url.replace(AMPERSAND_RE, "^&")]];
|
|
198
|
+
return ["xdg-open", [url]];
|
|
199
|
+
}
|
|
200
|
+
function openBrowser(url) {
|
|
201
|
+
const [command, args] = openCommand(url);
|
|
202
|
+
try {
|
|
203
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
204
|
+
child.on("error", () => {
|
|
135
205
|
});
|
|
136
|
-
|
|
206
|
+
child.unref();
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
137
209
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
report.output(`Creating project "${projectName}" in ${projectDir}
|
|
143
|
-
`);
|
|
144
|
-
mkdirSync(projectDir, { recursive: true });
|
|
145
|
-
report.step("Downloading the template\u2026");
|
|
146
|
-
report.output(`Downloading the template from ${templateUrl}
|
|
147
|
-
`);
|
|
148
|
-
const zipPath = await downloadZip(templateUrl, { report, signal, fetch });
|
|
210
|
+
function canOpenBrowserHere(env = process.env) {
|
|
211
|
+
return !env.SSH_CONNECTION && !env.SSH_TTY;
|
|
212
|
+
}
|
|
213
|
+
async function servesPage(url, { timeoutMs = 3e3, fetch = globalThis.fetch } = {}) {
|
|
149
214
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
rmSync(zipPath, { force: true });
|
|
215
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
216
|
+
await res.body?.cancel();
|
|
217
|
+
return res.ok && (res.headers.get("content-type") ?? "").includes("text/html");
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
156
220
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/dashboard/available.ts
|
|
224
|
+
import process2 from "process";
|
|
225
|
+
var AvailablePlugins = class {
|
|
226
|
+
constructor(url = process2.env[PLUGIN_REGISTRY_ENV] ?? PLUGIN_REGISTRY_URL, fetchRegistry = (registryUrl) => fetchPluginRegistry({ url: registryUrl })) {
|
|
227
|
+
this.fetchRegistry = fetchRegistry;
|
|
228
|
+
this.state = { status: "idle", url, plugins: [], error: null };
|
|
163
229
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
fetchRegistry;
|
|
231
|
+
state;
|
|
232
|
+
listeners = /* @__PURE__ */ new Set();
|
|
233
|
+
onChange(listener) {
|
|
234
|
+
this.listeners.add(listener);
|
|
235
|
+
return () => this.listeners.delete(listener);
|
|
169
236
|
}
|
|
170
|
-
|
|
171
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Fetch the list if it wasn't yet. With `force` it is fetched again, keeping what is shown until the answer is in.
|
|
239
|
+
*/
|
|
240
|
+
load(force = false) {
|
|
241
|
+
const { state } = this;
|
|
242
|
+
if (state.status === "loading" || state.status !== "idle" && !force)
|
|
243
|
+
return Promise.resolve();
|
|
244
|
+
state.status = "loading";
|
|
245
|
+
return this.fetchRegistry(state.url).then(({ plugins }) => {
|
|
246
|
+
state.plugins = plugins;
|
|
247
|
+
state.error = null;
|
|
248
|
+
state.status = "ready";
|
|
249
|
+
}, (err) => {
|
|
250
|
+
state.error = err.message;
|
|
251
|
+
state.status = "error";
|
|
252
|
+
}).then(() => this.listeners.forEach((listener) => listener()));
|
|
253
|
+
}
|
|
254
|
+
};
|
|
172
255
|
|
|
173
256
|
// src/dashboard/snapshot.ts
|
|
174
257
|
var SUPPORTED_SCHEMA_VERSION = 1;
|
|
@@ -300,10 +383,108 @@ function normalizeRequests(raw) {
|
|
|
300
383
|
latestId: num(r.latestId, entries.at(-1)?.id ?? 0)
|
|
301
384
|
};
|
|
302
385
|
}
|
|
386
|
+
function normalizePackets(raw) {
|
|
387
|
+
const r = section(raw);
|
|
388
|
+
const entries = arr(r.records, (e) => ({
|
|
389
|
+
id: num(e.id),
|
|
390
|
+
timestamp: num(e.timestamp),
|
|
391
|
+
kind: e.kind === "event" ? "event" : "packet",
|
|
392
|
+
direction: e.direction === "in" || e.direction === "out" ? e.direction : null,
|
|
393
|
+
type: str(e.type, "?"),
|
|
394
|
+
peer: strOrNull(e.peer),
|
|
395
|
+
bytes: num(e.bytes),
|
|
396
|
+
tick: numOrNull(e.tick),
|
|
397
|
+
sequenceId: numOrNull(e.sequenceId),
|
|
398
|
+
reliable: e.reliable === true,
|
|
399
|
+
info: section(e.info)
|
|
400
|
+
}));
|
|
401
|
+
return {
|
|
402
|
+
entries,
|
|
403
|
+
latestId: num(r.latestId, entries.at(-1)?.id ?? 0)
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function normalizeSync(raw) {
|
|
407
|
+
const r = section(raw);
|
|
408
|
+
const players = arr(r.players, (p) => {
|
|
409
|
+
const lastState = isRecord(p.lastState) ? p.lastState : null;
|
|
410
|
+
return {
|
|
411
|
+
id: str(p.id),
|
|
412
|
+
displayName: str(p.displayName, str(p.id)),
|
|
413
|
+
tracked: Array.isArray(p.tracked) ? p.tracked.length : 0,
|
|
414
|
+
ack: num(p.ack),
|
|
415
|
+
lastState: lastState && { tick: num(lastState.tick), bytes: num(lastState.bytes), entities: num(lastState.entities) }
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
const entities = arr(r.entities, (e) => ({ player: e.player === true, trackedBy: num(e.trackedBy) }));
|
|
419
|
+
return {
|
|
420
|
+
tick: num(r.tick),
|
|
421
|
+
tickRate: num(r.tickRate),
|
|
422
|
+
syncDistance: numOrNull(r.syncDistance),
|
|
423
|
+
entities: entities.length,
|
|
424
|
+
// Without players nothing is tracked, which says nothing about the entities
|
|
425
|
+
untracked: players.length ? entities.filter((e) => !e.player && e.trackedBy === 0).length : 0,
|
|
426
|
+
editors: Array.isArray(r.editors) ? r.editors.length : 0,
|
|
427
|
+
players
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function normalizePacketDetail(id, raw) {
|
|
431
|
+
const record = section(raw).record;
|
|
432
|
+
if (!isRecord(record))
|
|
433
|
+
return { id, status: "gone", lines: [] };
|
|
434
|
+
const lines = (value) => JSON.stringify(value, null, 2).split("\n");
|
|
435
|
+
if (record.payload !== void 0 && record.payload !== null)
|
|
436
|
+
return { id, status: "loaded", lines: lines(record.payload) };
|
|
437
|
+
if (record.kind === "event" && isRecord(record.info))
|
|
438
|
+
return { id, status: "loaded", lines: lines(record.info) };
|
|
439
|
+
if (record.kind !== "event" && num(record.bytes) > 0)
|
|
440
|
+
return { id, status: "gone", lines: [] };
|
|
441
|
+
return { id, status: "empty", lines: [] };
|
|
442
|
+
}
|
|
443
|
+
function descriptions(value) {
|
|
444
|
+
if (!isRecord(value))
|
|
445
|
+
return void 0;
|
|
446
|
+
return Object.fromEntries(Object.entries(value).map(([name, description]) => [name, str(description)]));
|
|
447
|
+
}
|
|
448
|
+
function normalizeRoutes(raw) {
|
|
449
|
+
return arr(section(raw).routes, (r) => {
|
|
450
|
+
const source = r.source === "engine" || r.source === "plugin" ? r.source : "game";
|
|
451
|
+
const plugin = strOrNull(r.plugin);
|
|
452
|
+
const query = descriptions(r.query);
|
|
453
|
+
return {
|
|
454
|
+
method: str(r.method, "?"),
|
|
455
|
+
path: str(r.path),
|
|
456
|
+
source,
|
|
457
|
+
...plugin ? { plugin } : {},
|
|
458
|
+
params: descriptions(r.params) ?? {},
|
|
459
|
+
devOnly: r.devOnly === true,
|
|
460
|
+
...query ? { query } : {},
|
|
461
|
+
...Object.fromEntries(["summary", "description", "response", "auth"].filter((key) => typeof r[key] === "string").map((key) => [key, r[key]]))
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
function strings(value) {
|
|
466
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
467
|
+
}
|
|
468
|
+
function normalizePlugins(raw) {
|
|
469
|
+
return arr(section(raw).plugins, (p) => {
|
|
470
|
+
const target = p.target === "client" || p.target === "server" || p.target === "both" ? p.target : void 0;
|
|
471
|
+
return {
|
|
472
|
+
package: str(p.package, "?"),
|
|
473
|
+
enabled: p.enabled !== false,
|
|
474
|
+
installed: p.installed !== false,
|
|
475
|
+
runningOnServer: p.runningOnServer === true,
|
|
476
|
+
declaredPrefabs: strings(p.declaredPrefabs),
|
|
477
|
+
usedPrefabs: strings(p.usedPrefabs),
|
|
478
|
+
...target ? { target } : {},
|
|
479
|
+
...Object.fromEntries(["problem", "version", "description", "license", "homepage", "engine"].filter((key) => typeof p[key] === "string").map((key) => [key, p[key]]))
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
}
|
|
303
483
|
|
|
304
484
|
// src/dashboard/MetricsClient.ts
|
|
305
485
|
var MAX_LOG_LINES = 1e3;
|
|
306
486
|
var MAX_REQUEST_LINES = 1e3;
|
|
487
|
+
var MAX_PACKET_LINES = 2e3;
|
|
307
488
|
var MetricsSourceError = class extends Error {
|
|
308
489
|
constructor(message, status) {
|
|
309
490
|
super(message);
|
|
@@ -314,6 +495,11 @@ var MetricsSourceError = class extends Error {
|
|
|
314
495
|
function httpSource({ url, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
|
|
315
496
|
const logsUrl = new URL("logs", url);
|
|
316
497
|
const requestsUrl = new URL("requests", url);
|
|
498
|
+
const packetsUrl = new URL("dev/net/packets", url);
|
|
499
|
+
packetsUrl.searchParams.set("limit", String(MAX_PACKET_LINES));
|
|
500
|
+
const syncUrl = new URL("dev/net/sync", url);
|
|
501
|
+
const routesUrl = new URL("routes", url);
|
|
502
|
+
const pluginsUrl = new URL("plugins", url);
|
|
317
503
|
async function request(target) {
|
|
318
504
|
let res;
|
|
319
505
|
try {
|
|
@@ -344,7 +530,15 @@ function httpSource({ url, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
|
|
|
344
530
|
fetchRequests: (afterId) => {
|
|
345
531
|
requestsUrl.searchParams.set("after", String(afterId));
|
|
346
532
|
return request(requestsUrl.toString());
|
|
347
|
-
}
|
|
533
|
+
},
|
|
534
|
+
fetchPackets: (afterId) => {
|
|
535
|
+
packetsUrl.searchParams.set("after", String(afterId));
|
|
536
|
+
return request(packetsUrl.toString());
|
|
537
|
+
},
|
|
538
|
+
fetchSync: () => request(syncUrl.toString()),
|
|
539
|
+
fetchPacket: (id) => request(new URL(`dev/net/packets/${id}`, url).toString()),
|
|
540
|
+
fetchRoutes: () => request(routesUrl.toString()),
|
|
541
|
+
fetchPlugins: () => request(pluginsUrl.toString())
|
|
348
542
|
};
|
|
349
543
|
}
|
|
350
544
|
var Feed = class {
|
|
@@ -383,13 +577,16 @@ var Feed = class {
|
|
|
383
577
|
}
|
|
384
578
|
};
|
|
385
579
|
var MetricsClient = class {
|
|
386
|
-
constructor(source, { intervalMs = 1e3, maxBackoffMs = 1e4, startupIntervalMs } = {}) {
|
|
580
|
+
constructor(source, { intervalMs = 1e3, maxBackoffMs = 1e4, startupIntervalMs, routesIntervalMs = 5e3 } = {}) {
|
|
387
581
|
this.source = source;
|
|
388
582
|
this.intervalMs = intervalMs;
|
|
583
|
+
this.routesIntervalMs = routesIntervalMs;
|
|
389
584
|
this.maxBackoffMs = maxBackoffMs;
|
|
390
585
|
this.startupIntervalMs = startupIntervalMs;
|
|
391
586
|
this.logs = new Feed(source.fetchLogs?.bind(source), normalizeLogs, MAX_LOG_LINES);
|
|
392
587
|
this.requests = new Feed(source.fetchRequests?.bind(source), normalizeRequests, MAX_REQUEST_LINES);
|
|
588
|
+
this.packets = new Feed(source.fetchPackets?.bind(source), normalizePackets, MAX_PACKET_LINES);
|
|
589
|
+
this.syncAvailable = !!source.fetchSync;
|
|
393
590
|
this.state = {
|
|
394
591
|
status: "connecting",
|
|
395
592
|
source: source.label,
|
|
@@ -400,7 +597,15 @@ var MetricsClient = class {
|
|
|
400
597
|
logs: [],
|
|
401
598
|
logsAvailable: this.logs.available,
|
|
402
599
|
requests: [],
|
|
403
|
-
requestsAvailable: this.requests.available
|
|
600
|
+
requestsAvailable: this.requests.available,
|
|
601
|
+
packets: [],
|
|
602
|
+
packetsAvailable: this.packets.available,
|
|
603
|
+
sync: null,
|
|
604
|
+
packetDetail: null,
|
|
605
|
+
routes: [],
|
|
606
|
+
routesAvailable: source.fetchRoutes ? null : false,
|
|
607
|
+
plugins: [],
|
|
608
|
+
pluginsAvailable: source.fetchPlugins ? null : false
|
|
404
609
|
};
|
|
405
610
|
}
|
|
406
611
|
source;
|
|
@@ -413,6 +618,46 @@ var MetricsClient = class {
|
|
|
413
618
|
listeners = /* @__PURE__ */ new Set();
|
|
414
619
|
logs;
|
|
415
620
|
requests;
|
|
621
|
+
packets;
|
|
622
|
+
syncAvailable;
|
|
623
|
+
/**
|
|
624
|
+
* Follow the packets and the state sync too. Off by default: it is a lot of data on a busy server,
|
|
625
|
+
* so the dashboard only turns it on while its Network tab is shown.
|
|
626
|
+
*/
|
|
627
|
+
watchNetwork = false;
|
|
628
|
+
routesIntervalMs;
|
|
629
|
+
/** Date.now() the endpoints and plugins were last fetched, null to fetch them with the next poll */
|
|
630
|
+
projectFetchedAt = null;
|
|
631
|
+
/**
|
|
632
|
+
* Load the content of one packet into `state.packetDetail`, or close it with null.
|
|
633
|
+
*/
|
|
634
|
+
async inspectPacket(id) {
|
|
635
|
+
if (id === null) {
|
|
636
|
+
this.state.packetDetail = null;
|
|
637
|
+
this.notify();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (!this.source.fetchPacket) {
|
|
641
|
+
this.state.packetDetail = { id, status: "error", lines: [], error: "This source does not serve packet contents" };
|
|
642
|
+
this.notify();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
this.state.packetDetail = { id, status: "loading", lines: [] };
|
|
646
|
+
this.notify();
|
|
647
|
+
let detail;
|
|
648
|
+
try {
|
|
649
|
+
detail = normalizePacketDetail(id, await this.source.fetchPacket(id));
|
|
650
|
+
} catch (err) {
|
|
651
|
+
detail = { id, status: "error", lines: [], error: err.message };
|
|
652
|
+
}
|
|
653
|
+
if (this.state.packetDetail?.id !== id)
|
|
654
|
+
return;
|
|
655
|
+
this.state.packetDetail = detail;
|
|
656
|
+
this.notify();
|
|
657
|
+
}
|
|
658
|
+
notify() {
|
|
659
|
+
this.listeners.forEach((listener) => listener(this.state));
|
|
660
|
+
}
|
|
416
661
|
onChange(listener) {
|
|
417
662
|
this.listeners.add(listener);
|
|
418
663
|
return () => this.listeners.delete(listener);
|
|
@@ -436,6 +681,7 @@ var MetricsClient = class {
|
|
|
436
681
|
if (this.timer)
|
|
437
682
|
clearTimeout(this.timer);
|
|
438
683
|
this.timer = null;
|
|
684
|
+
this.projectFetchedAt = null;
|
|
439
685
|
await this.loop();
|
|
440
686
|
}
|
|
441
687
|
/**
|
|
@@ -449,25 +695,79 @@ var MetricsClient = class {
|
|
|
449
695
|
this.state.lastUpdate = Date.now();
|
|
450
696
|
this.state.error = null;
|
|
451
697
|
this.state.failures = 0;
|
|
452
|
-
await Promise.all([this.logs.poll(), this.requests.poll()]);
|
|
698
|
+
await Promise.all([this.logs.poll(), this.requests.poll(), this.pollProject()]);
|
|
453
699
|
this.state.logs = this.logs.entries;
|
|
454
700
|
this.state.logsAvailable = this.logs.available;
|
|
455
701
|
this.state.requests = this.requests.entries;
|
|
456
702
|
this.state.requestsAvailable = this.requests.available;
|
|
703
|
+
if (this.watchNetwork) {
|
|
704
|
+
await Promise.all([this.packets.poll(), this.pollSync()]);
|
|
705
|
+
this.state.packets = this.packets.entries;
|
|
706
|
+
this.state.packetsAvailable = this.packets.available;
|
|
707
|
+
}
|
|
457
708
|
} catch (err) {
|
|
458
709
|
this.state.status = err instanceof MetricsSourceError ? err.status : "unreachable";
|
|
459
710
|
this.state.error = err.message;
|
|
460
711
|
this.state.failures++;
|
|
461
712
|
}
|
|
462
|
-
this.
|
|
713
|
+
this.notify();
|
|
714
|
+
}
|
|
715
|
+
async pollSync() {
|
|
716
|
+
if (!this.syncAvailable)
|
|
717
|
+
return;
|
|
718
|
+
try {
|
|
719
|
+
this.state.sync = normalizeSync(await this.source.fetchSync());
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err instanceof MetricsSourceError && err.status === "disabled") {
|
|
722
|
+
this.syncAvailable = false;
|
|
723
|
+
this.state.sync = null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
463
726
|
}
|
|
464
727
|
/**
|
|
465
|
-
*
|
|
466
|
-
* The startup interval until the server first answered, if one is set.
|
|
728
|
+
* Fetch the server's HTTP endpoints and plugins, unless they were fetched a moment ago
|
|
467
729
|
*/
|
|
468
|
-
|
|
469
|
-
if (this.
|
|
470
|
-
return
|
|
730
|
+
async pollProject(now = Date.now()) {
|
|
731
|
+
if (this.projectFetchedAt !== null && now - this.projectFetchedAt < this.routesIntervalMs)
|
|
732
|
+
return;
|
|
733
|
+
const { source, state } = this;
|
|
734
|
+
const done = await Promise.all([
|
|
735
|
+
this.fetchList(state.routesAvailable, source.fetchRoutes?.bind(source), (raw) => {
|
|
736
|
+
state.routes = normalizeRoutes(raw);
|
|
737
|
+
state.routesAvailable = true;
|
|
738
|
+
}, () => state.routesAvailable = false),
|
|
739
|
+
this.fetchList(state.pluginsAvailable, source.fetchPlugins?.bind(source), (raw) => {
|
|
740
|
+
state.plugins = normalizePlugins(raw);
|
|
741
|
+
state.pluginsAvailable = true;
|
|
742
|
+
}, () => state.pluginsAvailable = false)
|
|
743
|
+
]);
|
|
744
|
+
if (done.every(Boolean))
|
|
745
|
+
this.projectFetchedAt = now;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Returns false when the list could not be fetched this time but may be next time
|
|
749
|
+
*/
|
|
750
|
+
async fetchList(available, fetchList, onList, onUnavailable) {
|
|
751
|
+
if (!fetchList || available === false)
|
|
752
|
+
return true;
|
|
753
|
+
try {
|
|
754
|
+
onList(await fetchList());
|
|
755
|
+
return true;
|
|
756
|
+
} catch (err) {
|
|
757
|
+
if (err instanceof MetricsSourceError && err.status === "disabled") {
|
|
758
|
+
onUnavailable();
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs.
|
|
766
|
+
* The startup interval until the server first answered, if one is set.
|
|
767
|
+
*/
|
|
768
|
+
nextDelay() {
|
|
769
|
+
if (this.startupIntervalMs !== void 0 && !this.state.snapshot)
|
|
770
|
+
return this.startupIntervalMs;
|
|
471
771
|
if (this.state.failures === 0)
|
|
472
772
|
return this.intervalMs;
|
|
473
773
|
return Math.min(this.intervalMs * 2 ** this.state.failures, Math.max(this.maxBackoffMs, this.intervalMs));
|
|
@@ -483,46 +783,50 @@ var MetricsClient = class {
|
|
|
483
783
|
}
|
|
484
784
|
};
|
|
485
785
|
|
|
486
|
-
// src/commands/dashboard.ts
|
|
487
|
-
import process3 from "process";
|
|
488
|
-
import { parseArgs } from "util";
|
|
489
|
-
|
|
490
|
-
// src/browser.ts
|
|
491
|
-
import { spawn as spawn2 } from "child_process";
|
|
492
|
-
import process2 from "process";
|
|
493
|
-
var AMPERSAND_RE = /&/g;
|
|
494
|
-
function openCommand(url, platform = process2.platform) {
|
|
495
|
-
if (platform === "darwin")
|
|
496
|
-
return ["open", [url]];
|
|
497
|
-
if (platform === "win32")
|
|
498
|
-
return ["cmd", ["/c", "start", '""', url.replace(AMPERSAND_RE, "^&")]];
|
|
499
|
-
return ["xdg-open", [url]];
|
|
500
|
-
}
|
|
501
|
-
function openBrowser(url) {
|
|
502
|
-
const [command, args] = openCommand(url);
|
|
503
|
-
try {
|
|
504
|
-
const child = spawn2(command, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
505
|
-
child.on("error", () => {
|
|
506
|
-
});
|
|
507
|
-
child.unref();
|
|
508
|
-
} catch {
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
function canOpenBrowserHere(env = process2.env) {
|
|
512
|
-
return !env.SSH_CONNECTION && !env.SSH_TTY;
|
|
513
|
-
}
|
|
514
|
-
async function servesPage(url, { timeoutMs = 3e3, fetch = globalThis.fetch } = {}) {
|
|
515
|
-
try {
|
|
516
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
517
|
-
await res.body?.cancel();
|
|
518
|
-
return res.ok && (res.headers.get("content-type") ?? "").includes("text/html");
|
|
519
|
-
} catch {
|
|
520
|
-
return false;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
786
|
// src/dashboard/simulator.ts
|
|
525
787
|
var HISTORY_LENGTH = 60;
|
|
788
|
+
var PACKET_LIMIT = 2e3;
|
|
789
|
+
var STATES_PER_SECOND = 5;
|
|
790
|
+
var SIMULATED_PLUGINS = [
|
|
791
|
+
{
|
|
792
|
+
package: "@mavonengine/http-monitoring",
|
|
793
|
+
enabled: true,
|
|
794
|
+
installed: true,
|
|
795
|
+
runningOnServer: true,
|
|
796
|
+
version: "0.0.1",
|
|
797
|
+
description: "The game server dashboard in the browser, behind HTTP Basic auth",
|
|
798
|
+
license: "MIT",
|
|
799
|
+
target: "server",
|
|
800
|
+
engine: "^0.0.22",
|
|
801
|
+
declaredPrefabs: [],
|
|
802
|
+
usedPrefabs: []
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
package: "@acme/forest",
|
|
806
|
+
enabled: true,
|
|
807
|
+
installed: true,
|
|
808
|
+
runningOnServer: false,
|
|
809
|
+
version: "2.3.1",
|
|
810
|
+
description: "Trees and rocks to place in a scene",
|
|
811
|
+
license: "MIT",
|
|
812
|
+
target: "client",
|
|
813
|
+
declaredPrefabs: ["Tree", "Rock"],
|
|
814
|
+
usedPrefabs: ["Tree"]
|
|
815
|
+
},
|
|
816
|
+
{ package: "chat", enabled: false, installed: true, runningOnServer: false, version: "1.0.0", target: "both", declaredPrefabs: [], usedPrefabs: [] }
|
|
817
|
+
];
|
|
818
|
+
var SIMULATED_ROUTES = [
|
|
819
|
+
{ method: "GET", path: "/api/game/health", source: "engine", params: {}, devOnly: false, summary: "Whether the server is up" },
|
|
820
|
+
{ method: "GET", path: "/api/game/metrics", source: "engine", params: {}, devOnly: false, summary: "Server statistics" },
|
|
821
|
+
{ method: "GET", path: "/api/game/logs", source: "engine", params: {}, devOnly: false, summary: "Recent server log entries" },
|
|
822
|
+
{ method: "GET", path: "/api/game/requests", source: "engine", params: {}, devOnly: false, summary: "Recent HTTP requests to this server" },
|
|
823
|
+
{ method: "GET", path: "/api/game/routes", source: "engine", params: {}, devOnly: false, summary: "Every HTTP endpoint of this server" },
|
|
824
|
+
{ method: "GET", path: "/api/game/dev/scene", source: "engine", params: {}, devOnly: true, summary: "The active scene" },
|
|
825
|
+
{ method: "GET", path: "/api/scores", source: "game", params: {}, devOnly: false, summary: "The high scores" },
|
|
826
|
+
{ method: "POST", path: "/api/scores/:player", source: "game", params: { player: "Id of the player" }, devOnly: false },
|
|
827
|
+
{ method: "GET", path: "/monitoring", source: "plugin", plugin: "@mavonengine/http-monitoring", params: {}, devOnly: false },
|
|
828
|
+
{ method: "GET", path: "/monitoring/api/metrics", source: "plugin", plugin: "@mavonengine/http-monitoring", params: {}, devOnly: false }
|
|
829
|
+
];
|
|
526
830
|
function mulberry32(seed) {
|
|
527
831
|
return () => {
|
|
528
832
|
seed |= 0;
|
|
@@ -557,6 +861,15 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
|
|
|
557
861
|
const request = (method, path, status, bytes, address) => {
|
|
558
862
|
requests.push({ id: requests.length + 1, timestamp: now, method, path, status, durationMs: Math.round((1 + random() * 20) * 100) / 100, bytes, address });
|
|
559
863
|
};
|
|
864
|
+
const packets = [];
|
|
865
|
+
let packetId = 0;
|
|
866
|
+
let tick = 0;
|
|
867
|
+
let syncPlayers = [];
|
|
868
|
+
const record = (entry, offsetMs = 0) => {
|
|
869
|
+
packets.push({ id: ++packetId, timestamp: now + offsetMs, hasPayload: entry.kind === "packet", ...entry });
|
|
870
|
+
if (packets.length > PACKET_LIMIT)
|
|
871
|
+
packets.shift();
|
|
872
|
+
};
|
|
560
873
|
log("info", "Starting @mavon/engine - Socket Server");
|
|
561
874
|
log("info", "Starting @mavon/engine - HTTP Server");
|
|
562
875
|
return {
|
|
@@ -594,14 +907,48 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
|
|
|
594
907
|
request("GET", "/favicon.ico", 404, 160, address);
|
|
595
908
|
request("POST", "/.wrtc/v2/connections", 200, 2900, address);
|
|
596
909
|
request("POST", `/.wrtc/v2/connections/${players[i].id}/additional-candidates`, 200, 600, address);
|
|
910
|
+
record({ kind: "event", type: "connect", peer: players[i].id });
|
|
911
|
+
record({ kind: "packet", direction: "in", type: "cl_init", peer: players[i].id, bytes: 24, reliable: true });
|
|
912
|
+
record({ kind: "event", type: "sync_enter", peer: players[i].id, tick, info: { count: players[i].trackedEntities } });
|
|
597
913
|
log("info", `Connected: ${players[i].id}`);
|
|
598
914
|
log("info", `${i + 1} total players connected`);
|
|
599
915
|
}
|
|
600
916
|
for (let i = previousPlayers - 1; i >= playerCount; i--) {
|
|
917
|
+
record({ kind: "event", type: "disconnect", peer: `sim-${(i + 1).toString().padStart(2, "0")}` });
|
|
601
918
|
log("info", `Disconnected sim-${(i + 1).toString().padStart(2, "0")}`);
|
|
602
919
|
log("info", `${i} total players connected`);
|
|
603
920
|
}
|
|
604
921
|
previousPlayers = playerCount;
|
|
922
|
+
const lastStates = /* @__PURE__ */ new Map();
|
|
923
|
+
for (let state = 0; state < STATES_PER_SECOND; state++) {
|
|
924
|
+
const offset = Math.round(state * sampleIntervalMs / STATES_PER_SECOND) - sampleIntervalMs;
|
|
925
|
+
tick += tickRate / STATES_PER_SECOND;
|
|
926
|
+
for (const player of players) {
|
|
927
|
+
const bytes = 180 + player.trackedEntities * 96 + Math.floor(random() * 40);
|
|
928
|
+
if (state % 2 === 0)
|
|
929
|
+
record({ kind: "packet", direction: "in", type: "command", peer: player.id, bytes: 58, sequenceId: player.lastProcessedSequenceId, info: { command: "cl_move" } }, offset);
|
|
930
|
+
record({ kind: "packet", direction: "out", type: "sv_state", peer: player.id, bytes, tick, info: { entities: player.trackedEntities, ack: player.lastProcessedSequenceId } }, offset + 2);
|
|
931
|
+
lastStates.set(player.id, { tick, bytes, entities: player.trackedEntities });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
for (const player of players) {
|
|
935
|
+
record({ kind: "packet", direction: "in", type: "ping", peer: player.id, bytes: 0 });
|
|
936
|
+
record({ kind: "packet", direction: "out", type: "pong", peer: player.id, bytes: 0 });
|
|
937
|
+
}
|
|
938
|
+
if (step % 9 === 0)
|
|
939
|
+
record({ kind: "event", type: "sync_leave", peer: players[0].id, tick, info: { count: 2, entities: ["rock-12", "rock-40"] } });
|
|
940
|
+
if (step % 13 === 0)
|
|
941
|
+
record({ kind: "event", type: "dropped", peer: players.at(-1).id, info: { reason: "DROPPED_FROM_BUFFERING", message: "sv_state" } });
|
|
942
|
+
if (step % 4 === 0)
|
|
943
|
+
record({ kind: "packet", direction: "out", type: "sv_chat", peer: players[0].id, bytes: 74, reliable: true });
|
|
944
|
+
syncPlayers = players.map((player) => ({
|
|
945
|
+
id: player.id,
|
|
946
|
+
displayName: player.displayName,
|
|
947
|
+
position: player.position,
|
|
948
|
+
ack: player.lastProcessedSequenceId,
|
|
949
|
+
tracked: Array.from({ length: player.trackedEntities }, (_, i) => `entity-${i}`),
|
|
950
|
+
lastState: lastStates.get(player.id) ?? null
|
|
951
|
+
}));
|
|
605
952
|
if (step % 10 === 0)
|
|
606
953
|
request("GET", "/api/game/health", 200, 180, "198.51.100.7");
|
|
607
954
|
if (step % 23 === 0)
|
|
@@ -674,6 +1021,47 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
|
|
|
674
1021
|
},
|
|
675
1022
|
async fetchRequests(afterId) {
|
|
676
1023
|
return { entries: requests.filter((entry) => entry.id > afterId), latestId: requests.length };
|
|
1024
|
+
},
|
|
1025
|
+
async fetchPackets(afterId) {
|
|
1026
|
+
const records = packets.filter((entry) => entry.id > afterId);
|
|
1027
|
+
return { enabled: true, latestId: packetId, matched: records.length, records };
|
|
1028
|
+
},
|
|
1029
|
+
async fetchPacket(id) {
|
|
1030
|
+
const found = packets.find((entry) => entry.id === id);
|
|
1031
|
+
if (!found)
|
|
1032
|
+
return { record: null };
|
|
1033
|
+
if (found.kind === "event" || !found.bytes)
|
|
1034
|
+
return { record: found };
|
|
1035
|
+
const info = found.info ?? {};
|
|
1036
|
+
const payload = found.type === "sv_state" ? {
|
|
1037
|
+
type: "sv_state",
|
|
1038
|
+
sequenceId: 0,
|
|
1039
|
+
tick: found.tick,
|
|
1040
|
+
serverTime: found.timestamp,
|
|
1041
|
+
ack: info.ack,
|
|
1042
|
+
entities: Array.from({ length: info.entities ?? 0 }, (_, i) => ({
|
|
1043
|
+
id: i === 0 ? found.peer : `entity-${i}`,
|
|
1044
|
+
$typeName: i === 0 ? "player" : "rock",
|
|
1045
|
+
position: { x: i * 3, y: 0, z: -i * 2 },
|
|
1046
|
+
rotation: { x: 0, y: i % 6 / 2, z: 0 },
|
|
1047
|
+
scale: { x: 1, y: 1, z: 1 }
|
|
1048
|
+
}))
|
|
1049
|
+
} : found.type === "command" ? { type: "cl_move", sequenceId: found.sequenceId, keys: ["KeyW"], yaw: 1.57 } : found.type === "cl_init" ? { name: `Player ${String(found.peer).slice(-2)}` } : { playerId: found.peer, playerName: "Player 1", message: "hello" };
|
|
1050
|
+
return { record: { ...found, payload } };
|
|
1051
|
+
},
|
|
1052
|
+
async fetchSync() {
|
|
1053
|
+
const entities = [
|
|
1054
|
+
...syncPlayers.map((player) => ({ id: player.id, type: "player", player: true, trackedBy: syncPlayers.length })),
|
|
1055
|
+
// The last few are out of every player's range
|
|
1056
|
+
...Array.from({ length: 140 }, (_, i) => ({ id: `entity-${i}`, type: "rock", player: false, trackedBy: i < 134 ? 1 + i % syncPlayers.length : 0 }))
|
|
1057
|
+
];
|
|
1058
|
+
return { tick, serverTime: now, tickRate, syncDistance: 200, players: syncPlayers, editors: [], entities };
|
|
1059
|
+
},
|
|
1060
|
+
async fetchRoutes() {
|
|
1061
|
+
return { routes: SIMULATED_ROUTES };
|
|
1062
|
+
},
|
|
1063
|
+
async fetchPlugins() {
|
|
1064
|
+
return { plugins: SIMULATED_PLUGINS };
|
|
677
1065
|
}
|
|
678
1066
|
};
|
|
679
1067
|
}
|
|
@@ -715,6 +1103,39 @@ function bootBanner({ version: version2, threeRevision, rapierVersion, mode }) {
|
|
|
715
1103
|
// src/dashboard/view.ts
|
|
716
1104
|
import { DEFAULT_COLOR, hex } from "@profullstack/hqtui";
|
|
717
1105
|
|
|
1106
|
+
// src/dashboard/format.ts
|
|
1107
|
+
var BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
1108
|
+
function formatBytes(bytes, decimals = 1) {
|
|
1109
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
1110
|
+
return "0 B";
|
|
1111
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
1112
|
+
const value = bytes / 1024 ** exponent;
|
|
1113
|
+
return `${exponent === 0 ? value : value.toFixed(decimals)} ${BYTE_UNITS[exponent]}`;
|
|
1114
|
+
}
|
|
1115
|
+
function formatRate(bytesPerSecond) {
|
|
1116
|
+
return `${formatBytes(bytesPerSecond)}/s`;
|
|
1117
|
+
}
|
|
1118
|
+
function formatDuration(totalSeconds) {
|
|
1119
|
+
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
1120
|
+
const d = Math.floor(seconds / 86400);
|
|
1121
|
+
const h = Math.floor(seconds % 86400 / 3600);
|
|
1122
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1123
|
+
const s = seconds % 60;
|
|
1124
|
+
if (d > 0)
|
|
1125
|
+
return `${d}d ${h}h ${m}m`;
|
|
1126
|
+
if (h > 0)
|
|
1127
|
+
return `${h}h ${m}m ${s}s`;
|
|
1128
|
+
if (m > 0)
|
|
1129
|
+
return `${m}m ${s}s`;
|
|
1130
|
+
return `${s}s`;
|
|
1131
|
+
}
|
|
1132
|
+
function formatMs(ms) {
|
|
1133
|
+
return `${ms.toFixed(ms < 10 ? 2 : 1)} ms`;
|
|
1134
|
+
}
|
|
1135
|
+
function formatPercent(value) {
|
|
1136
|
+
return `${value.toFixed(1)}%`;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
718
1139
|
// src/dashboard/logo.ts
|
|
719
1140
|
var LOGO_SIZES = [
|
|
720
1141
|
[
|
|
@@ -804,7 +1225,14 @@ var LOGO_SIZES = [
|
|
|
804
1225
|
];
|
|
805
1226
|
|
|
806
1227
|
// src/dashboard/view.ts
|
|
807
|
-
|
|
1228
|
+
function initialViewState() {
|
|
1229
|
+
return { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true, packetTopId: null, showPings: false, eventsOnly: false, selectedPacketId: null, followSelectedPacket: true, payloadOffset: 0 };
|
|
1230
|
+
}
|
|
1231
|
+
var DASHBOARD_TABS = ["Overview", "Players", "Logs", "HTTP", "Network", "Routes", "Plugins"];
|
|
1232
|
+
function showsNetwork(view) {
|
|
1233
|
+
return DASHBOARD_TABS[view.tab] === "Network";
|
|
1234
|
+
}
|
|
1235
|
+
var MESSAGE_STATS_WINDOW_MS = 5e3;
|
|
808
1236
|
var WIDE_LAYOUT = 100;
|
|
809
1237
|
var NEWLINE_RE = /\r?\n/;
|
|
810
1238
|
var STATUS_LABELS = {
|
|
@@ -840,6 +1268,123 @@ function handlePlayersKey(view, snapshot, key) {
|
|
|
840
1268
|
view.followSelectedPlayer = true;
|
|
841
1269
|
return true;
|
|
842
1270
|
}
|
|
1271
|
+
function selectPacket(view, id) {
|
|
1272
|
+
view.selectedPacketId = id;
|
|
1273
|
+
view.payloadOffset = 0;
|
|
1274
|
+
view.followSelectedPacket = true;
|
|
1275
|
+
}
|
|
1276
|
+
function handleNetworkKey(view, key, packets = []) {
|
|
1277
|
+
if (!showsNetwork(view))
|
|
1278
|
+
return false;
|
|
1279
|
+
if (key === "escape") {
|
|
1280
|
+
const had = view.selectedPacketId !== null;
|
|
1281
|
+
selectPacket(view, null);
|
|
1282
|
+
return had;
|
|
1283
|
+
}
|
|
1284
|
+
const delta = key === "down" || key === "j" ? 1 : key === "up" || key === "k" ? -1 : 0;
|
|
1285
|
+
if (delta !== 0) {
|
|
1286
|
+
const rows = visiblePackets(packets, view).reverse();
|
|
1287
|
+
if (rows.length === 0)
|
|
1288
|
+
return false;
|
|
1289
|
+
const current = rows.findIndex((p) => p.id === view.selectedPacketId);
|
|
1290
|
+
const next = current === -1 ? delta > 0 ? 0 : rows.length - 1 : Math.max(0, Math.min(rows.length - 1, current + delta));
|
|
1291
|
+
selectPacket(view, rows[next].id);
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
if (key === "p")
|
|
1295
|
+
view.showPings = !view.showPings;
|
|
1296
|
+
else if (key === "e")
|
|
1297
|
+
view.eventsOnly = !view.eventsOnly;
|
|
1298
|
+
else
|
|
1299
|
+
return false;
|
|
1300
|
+
view.packetTopId = null;
|
|
1301
|
+
return true;
|
|
1302
|
+
}
|
|
1303
|
+
function messageStats(packets, windowMs = MESSAGE_STATS_WINDOW_MS) {
|
|
1304
|
+
const newest = packets.at(-1)?.timestamp;
|
|
1305
|
+
if (newest === void 0)
|
|
1306
|
+
return [];
|
|
1307
|
+
const recent = packets.filter((p) => p.kind === "packet" && p.direction && p.timestamp > newest - windowMs);
|
|
1308
|
+
const seconds = Math.max(1e3, Math.min(windowMs, newest - (packets[0]?.timestamp ?? newest))) / 1e3;
|
|
1309
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1310
|
+
for (const packet of recent) {
|
|
1311
|
+
const key = `${packet.direction} ${packet.type}`;
|
|
1312
|
+
const group = groups.get(key) ?? { direction: packet.direction, type: packet.type, count: 0, bytes: 0 };
|
|
1313
|
+
group.count++;
|
|
1314
|
+
group.bytes += packet.bytes;
|
|
1315
|
+
groups.set(key, group);
|
|
1316
|
+
}
|
|
1317
|
+
return Array.from(groups.values(), ({ direction, type, count, bytes }) => ({ direction, type, rate: count / seconds, bytesPerSecond: bytes / seconds, avgBytes: bytes / count })).sort((a, b) => b.bytesPerSecond - a.bytesPerSecond || b.rate - a.rate || a.type.localeCompare(b.type));
|
|
1318
|
+
}
|
|
1319
|
+
function describePacket(packet) {
|
|
1320
|
+
const parts = [];
|
|
1321
|
+
if (packet.tick !== null)
|
|
1322
|
+
parts.push(`tick ${packet.tick}`);
|
|
1323
|
+
if (packet.sequenceId !== null)
|
|
1324
|
+
parts.push(`seq ${packet.sequenceId}`);
|
|
1325
|
+
if (packet.reliable)
|
|
1326
|
+
parts.push("reliable");
|
|
1327
|
+
for (const [key, value] of Object.entries(packet.info)) {
|
|
1328
|
+
if (value === void 0 || value === null)
|
|
1329
|
+
continue;
|
|
1330
|
+
parts.push(`${key} ${Array.isArray(value) ? value.join(", ") : typeof value === "object" ? JSON.stringify(value) : String(value)}`);
|
|
1331
|
+
}
|
|
1332
|
+
return parts.join(" \xB7 ");
|
|
1333
|
+
}
|
|
1334
|
+
function installable(plugins, available) {
|
|
1335
|
+
return available ? notInProject(available.state.plugins, plugins.map((p) => p.package)) : [];
|
|
1336
|
+
}
|
|
1337
|
+
function selectedPlugin(view, plugins, rest = []) {
|
|
1338
|
+
const installed = plugins.find((p) => p.package === view.selectedPlugin);
|
|
1339
|
+
if (installed)
|
|
1340
|
+
return { installed };
|
|
1341
|
+
const available = rest.find((p) => p.package === view.selectedPlugin);
|
|
1342
|
+
if (available)
|
|
1343
|
+
return { available };
|
|
1344
|
+
return plugins.length ? { installed: plugins[0] } : { available: rest[0] };
|
|
1345
|
+
}
|
|
1346
|
+
function handlePluginsKey(view, plugins, key, rest = []) {
|
|
1347
|
+
if (DASHBOARD_TABS[view.tab] !== "Plugins" || view.confirmInstall)
|
|
1348
|
+
return false;
|
|
1349
|
+
const delta = key === "down" || key === "j" ? 1 : key === "up" || key === "k" ? -1 : 0;
|
|
1350
|
+
const packages = [...plugins, ...rest].map((p) => p.package);
|
|
1351
|
+
if (delta === 0 || packages.length === 0)
|
|
1352
|
+
return false;
|
|
1353
|
+
const shown = selectedPlugin(view, plugins, rest);
|
|
1354
|
+
const current = Math.max(0, packages.indexOf((shown.installed ?? shown.available).package));
|
|
1355
|
+
view.selectedPlugin = packages[Math.max(0, Math.min(packages.length - 1, current + delta))];
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
function handleInstallKey(view, plugins, key, rest, installer) {
|
|
1359
|
+
if (DASHBOARD_TABS[view.tab] !== "Plugins" || !installer)
|
|
1360
|
+
return false;
|
|
1361
|
+
if (view.confirmInstall) {
|
|
1362
|
+
const pkg = view.confirmInstall;
|
|
1363
|
+
if (key === "y") {
|
|
1364
|
+
view.confirmInstall = null;
|
|
1365
|
+
return installer.busy ? true : pkg;
|
|
1366
|
+
}
|
|
1367
|
+
if (key === "n" || key === "escape") {
|
|
1368
|
+
view.confirmInstall = null;
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
const { available } = selectedPlugin(view, plugins, rest);
|
|
1374
|
+
if (key !== "i" || !available || installer.busy)
|
|
1375
|
+
return false;
|
|
1376
|
+
view.confirmInstall = available.package;
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
function pluginKeys(state, view, available, installer) {
|
|
1380
|
+
if (view.confirmInstall)
|
|
1381
|
+
return [{ key: "y", label: "Install" }, { key: "n", label: "Cancel" }];
|
|
1382
|
+
const rest = installable(state.plugins, available);
|
|
1383
|
+
return [
|
|
1384
|
+
...state.plugins.length + rest.length > 1 ? [{ key: "\u2191\u2193", label: "Select plugin" }] : [],
|
|
1385
|
+
...installer && !installer.busy && selectedPlugin(view, state.plugins, rest).available ? [{ key: "i", label: "Install" }] : []
|
|
1386
|
+
];
|
|
1387
|
+
}
|
|
843
1388
|
function statusColor(status, theme) {
|
|
844
1389
|
if (status === "live")
|
|
845
1390
|
return theme.success;
|
|
@@ -847,7 +1392,7 @@ function statusColor(status, theme) {
|
|
|
847
1392
|
return theme.warning;
|
|
848
1393
|
return theme.danger;
|
|
849
1394
|
}
|
|
850
|
-
function renderDashboard(ui, theme, state, view, { transparent = false, startup, openKey = false } = {}) {
|
|
1395
|
+
function renderDashboard(ui, theme, state, view, { transparent = false, startup, openKey = false, available, installer } = {}) {
|
|
851
1396
|
const { snapshot } = state;
|
|
852
1397
|
const starting = !snapshot && !!startup && (state.status === "connecting" || state.status === "unreachable");
|
|
853
1398
|
ui.column({}, (root) => {
|
|
@@ -877,6 +1422,12 @@ function renderDashboard(ui, theme, state, view, { transparent = false, startup,
|
|
|
877
1422
|
logsPanel(root, theme, state, view);
|
|
878
1423
|
} else if (tab === "HTTP") {
|
|
879
1424
|
requestsPanel(root, theme, state, view, transparent);
|
|
1425
|
+
} else if (tab === "Network") {
|
|
1426
|
+
networkTab(root, theme, state, view, transparent);
|
|
1427
|
+
} else if (tab === "Routes") {
|
|
1428
|
+
routesPanel(root, theme, state, view, transparent);
|
|
1429
|
+
} else if (tab === "Plugins") {
|
|
1430
|
+
pluginsTab(root, theme, state, view, transparent, available, installer);
|
|
880
1431
|
} else {
|
|
881
1432
|
root.responsive({
|
|
882
1433
|
[WIDE_LAYOUT]: (c) => wideLayout(c, theme, state, snapshot, view, transparent),
|
|
@@ -892,7 +1443,14 @@ function renderDashboard(ui, theme, state, view, { transparent = false, startup,
|
|
|
892
1443
|
{ key: "r", label: "Refresh" },
|
|
893
1444
|
{ key: `1-${DASHBOARD_TABS.length}`, label: "Tabs" },
|
|
894
1445
|
...openKey && snapshot ? [{ key: "o", label: "Open game" }] : [],
|
|
895
|
-
...snapshot && DASHBOARD_TABS[view.tab] === "Players" ? [{ key: "\u2191\u2193", label: "Select player" }, ...view.selectedPlayerId ? [{ key: "esc", label: "Close" }] : []] : []
|
|
1446
|
+
...snapshot && DASHBOARD_TABS[view.tab] === "Players" ? [{ key: "\u2191\u2193", label: "Select player" }, ...view.selectedPlayerId ? [{ key: "esc", label: "Close" }] : []] : [],
|
|
1447
|
+
...snapshot && showsNetwork(view) ? [
|
|
1448
|
+
{ key: "p", label: view.showPings ? "Hide pings" : "Show pings" },
|
|
1449
|
+
{ key: "e", label: view.eventsOnly ? "All records" : "Events only" },
|
|
1450
|
+
{ key: "\u2191\u2193", label: "Open packet" },
|
|
1451
|
+
...view.selectedPacketId !== null ? [{ key: "esc", label: "Close" }] : []
|
|
1452
|
+
] : [],
|
|
1453
|
+
...snapshot && DASHBOARD_TABS[view.tab] === "Plugins" ? pluginKeys(state, view, available, installer) : []
|
|
896
1454
|
],
|
|
897
1455
|
right: [starting ? { label: "STARTING", color: statusColor("connecting", theme) } : { label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === "live" }]
|
|
898
1456
|
});
|
|
@@ -1269,128 +1827,1123 @@ function requestsPanel(ui, theme, state, view, transparent) {
|
|
|
1269
1827
|
});
|
|
1270
1828
|
});
|
|
1271
1829
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
var
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
`;
|
|
1290
|
-
var STARTUP_POLL_MS = 250;
|
|
1291
|
-
var UsageError = class extends Error {
|
|
1292
|
-
};
|
|
1293
|
-
function parseDashboardArgs(args, env = process3.env) {
|
|
1294
|
-
const { values } = parseArgs({
|
|
1295
|
-
args,
|
|
1296
|
-
options: {
|
|
1297
|
-
host: { type: "string", default: "localhost" },
|
|
1298
|
-
port: { type: "string", default: "8050" },
|
|
1299
|
-
url: { type: "string" },
|
|
1300
|
-
token: { type: "string" },
|
|
1301
|
-
interval: { type: "string", default: "1000" },
|
|
1302
|
-
theme: { type: "string" },
|
|
1303
|
-
transparent: { type: "boolean", default: false },
|
|
1304
|
-
sim: { type: "boolean", default: false },
|
|
1305
|
-
help: { type: "boolean", short: "h", default: false }
|
|
1306
|
-
},
|
|
1307
|
-
strict: true,
|
|
1308
|
-
allowPositionals: false
|
|
1830
|
+
var NETWORK_UNAVAILABLE = "Not available: the game server runs with NODE_ENV=production, where nothing is recorded, or it runs a @mavonengine/core release without GET /api/game/dev/net";
|
|
1831
|
+
var NARROW_PACKET_DETAILS_SIZE = 12;
|
|
1832
|
+
var NETWORK_SUMMARY_SIZE = 10;
|
|
1833
|
+
function networkTab(ui, theme, state, view, transparent) {
|
|
1834
|
+
if (state.packetsAvailable === false) {
|
|
1835
|
+
ui.panel({ title: "Network", size: "1fr" }, (panel) => panel.text(NETWORK_UNAVAILABLE, { wrap: true }));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
ui.responsive({
|
|
1839
|
+
[WIDE_LAYOUT]: (c) => c.row({ size: NETWORK_SUMMARY_SIZE }, (row) => {
|
|
1840
|
+
syncPanel(row, theme, state, transparent);
|
|
1841
|
+
messagesPanel(row, theme, state, transparent);
|
|
1842
|
+
}),
|
|
1843
|
+
0: (c) => {
|
|
1844
|
+
syncPanel(c, theme, state, transparent, NETWORK_SUMMARY_SIZE);
|
|
1845
|
+
messagesPanel(c, theme, state, transparent, NETWORK_SUMMARY_SIZE);
|
|
1846
|
+
}
|
|
1309
1847
|
});
|
|
1310
|
-
if (
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
if (!Number.isInteger(intervalMs) || intervalMs < 100)
|
|
1314
|
-
throw new UsageError("--interval must be a whole number of ms, at least 100");
|
|
1315
|
-
if (values.sim)
|
|
1316
|
-
return { source: simulatedSource(), intervalMs, theme: values.theme, transparent: values.transparent };
|
|
1317
|
-
let url = values.url;
|
|
1318
|
-
if (!url) {
|
|
1319
|
-
const port = Number(values.port);
|
|
1320
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
1321
|
-
throw new UsageError("--port must be between 1 and 65535");
|
|
1322
|
-
const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
|
|
1323
|
-
url = `http://${host}:${port}/api/game/metrics`;
|
|
1848
|
+
if (view.selectedPacketId === null) {
|
|
1849
|
+
packetsPanel(ui, theme, state, view, transparent);
|
|
1850
|
+
return;
|
|
1324
1851
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1852
|
+
ui.responsive({
|
|
1853
|
+
[WIDE_LAYOUT]: (c) => c.row({ size: "1fr" }, (row) => {
|
|
1854
|
+
packetsPanel(row, theme, state, view, transparent);
|
|
1855
|
+
packetDetails(row, theme, state, view, "40%");
|
|
1856
|
+
}),
|
|
1857
|
+
0: (c) => {
|
|
1858
|
+
packetsPanel(c, theme, state, view, transparent);
|
|
1859
|
+
packetDetails(c, theme, state, view, NARROW_PACKET_DETAILS_SIZE);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
function packetDetails(ui, theme, state, view, size) {
|
|
1864
|
+
const id = view.selectedPacketId;
|
|
1865
|
+
const packet = state.packets.find((p) => p.id === id);
|
|
1866
|
+
const detail = state.packetDetail?.id === id ? state.packetDetail : null;
|
|
1867
|
+
const name = packet ? `${packet.type}${packet.direction ? ` ${packet.direction}` : ""}` : `#${id}`;
|
|
1868
|
+
const title = packet?.kind === "packet" ? `${name} \xB7 ${formatBytes(packet.bytes)}` : name;
|
|
1869
|
+
ui.panel({ title, size }, (panel) => {
|
|
1870
|
+
if (packet) {
|
|
1871
|
+
const facts = describePacket({ ...packet, info: {} });
|
|
1872
|
+
panel.text([
|
|
1873
|
+
{ text: formatTimeMs(packet.timestamp), fg: theme.muted },
|
|
1874
|
+
...packet.peer ? [{ text: ` ${packet.peer}` }] : [],
|
|
1875
|
+
...facts ? [{ text: ` ${facts}`, fg: theme.muted }] : []
|
|
1876
|
+
], { size: 1 });
|
|
1877
|
+
}
|
|
1878
|
+
if (!detail || detail.status === "loading") {
|
|
1879
|
+
panel.label("Loading\u2026");
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (detail.status === "error") {
|
|
1883
|
+
panel.text([{ text: detail.error ?? "Could not load it", fg: theme.danger }], { wrap: true });
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (detail.status === "gone") {
|
|
1887
|
+
panel.text("The server no longer keeps this payload: it keeps the last 2000 records and 8 MB of payloads.", { wrap: true });
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (detail.status === "empty") {
|
|
1891
|
+
panel.label("No payload");
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
const maxOffset = Math.max(0, detail.lines.length - 1);
|
|
1895
|
+
view.payloadOffset = Math.min(view.payloadOffset, maxOffset);
|
|
1896
|
+
panel.list({
|
|
1897
|
+
items: detail.lines,
|
|
1898
|
+
offset: view.payloadOffset,
|
|
1899
|
+
bullet: "",
|
|
1900
|
+
scrollbar: true,
|
|
1901
|
+
size: "1fr",
|
|
1902
|
+
onScroll: (delta) => {
|
|
1903
|
+
view.payloadOffset = Math.max(0, Math.min(maxOffset, view.payloadOffset + delta * 3));
|
|
1904
|
+
ui.ctx.invalidate();
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
function syncPanel(ui, theme, { sync }, transparent, size = "1fr") {
|
|
1910
|
+
ui.panel({ title: sync ? `State sync (tick ${sync.tick})` : "State sync", size }, (panel) => {
|
|
1911
|
+
if (!sync) {
|
|
1912
|
+
panel.label("Waiting for the server\u2026");
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
panel.text([
|
|
1916
|
+
{ text: `${sync.entities}`, bold: true },
|
|
1917
|
+
{ text: sync.entities === 1 ? " synced entity" : " synced entities", fg: theme.muted },
|
|
1918
|
+
...sync.untracked > 0 ? [{ text: `, ${sync.untracked} out of every player's range`, fg: theme.warning }] : [],
|
|
1919
|
+
{ text: " distance ", fg: theme.muted },
|
|
1920
|
+
{ text: sync.syncDistance === null ? "unknown" : String(sync.syncDistance) },
|
|
1921
|
+
...sync.editors > 0 ? [{ text: ` ${sync.editors} editor${sync.editors === 1 ? "" : "s"}`, fg: theme.info }] : []
|
|
1922
|
+
], { size: 1 });
|
|
1923
|
+
if (sync.players.length === 0) {
|
|
1924
|
+
panel.label("No players connected");
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const newest = Math.max(...sync.players.map((p) => p.lastState?.tick ?? 0));
|
|
1928
|
+
panel.table({
|
|
1929
|
+
rows: [...sync.players].sort((a, b) => a.id.localeCompare(b.id)),
|
|
1930
|
+
scrollbar: true,
|
|
1931
|
+
zebra: !transparent,
|
|
1932
|
+
size: "1fr",
|
|
1933
|
+
columns: [
|
|
1934
|
+
{ key: "displayName", title: "Player" },
|
|
1935
|
+
{ key: "tracked", title: "Tracked", width: 8, align: "right" },
|
|
1936
|
+
{ key: "ack", title: "Ack", width: 9, align: "right" },
|
|
1937
|
+
{ key: "bytes", title: "State", width: 10, align: "right", render: (p) => p.lastState ? formatBytes(p.lastState.bytes) : "\u2014" },
|
|
1938
|
+
{
|
|
1939
|
+
key: "tick",
|
|
1940
|
+
title: "Tick",
|
|
1941
|
+
width: 9,
|
|
1942
|
+
align: "right",
|
|
1943
|
+
color: (p) => !p.lastState || newest - p.lastState.tick > sync.tickRate ? theme.danger : void 0,
|
|
1944
|
+
render: (p) => p.lastState ? String(p.lastState.tick) : "none"
|
|
1945
|
+
}
|
|
1946
|
+
]
|
|
1947
|
+
});
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
function messagesPanel(ui, theme, state, transparent, size = "1fr") {
|
|
1951
|
+
const stats = messageStats(state.packets);
|
|
1952
|
+
ui.panel({ title: `By message (last ${MESSAGE_STATS_WINDOW_MS / 1e3} s)`, size }, (panel) => {
|
|
1953
|
+
if (stats.length === 0) {
|
|
1954
|
+
panel.label(state.packetsAvailable === null ? "Waiting for the server\u2026" : "No packets yet");
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
panel.table({
|
|
1958
|
+
rows: stats,
|
|
1959
|
+
scrollbar: true,
|
|
1960
|
+
zebra: !transparent,
|
|
1961
|
+
size: "1fr",
|
|
1962
|
+
columns: [
|
|
1963
|
+
{ key: "direction", title: "", width: 3, color: (s) => s.direction === "out" ? theme.graph[0] : theme.graph[1], render: (s) => s.direction === "out" ? "out" : "in" },
|
|
1964
|
+
{ key: "type", title: "Message" },
|
|
1965
|
+
{ key: "rate", title: "Per s", width: 8, align: "right", render: (s) => s.rate.toFixed(1) },
|
|
1966
|
+
{ key: "avgBytes", title: "Avg size", width: 10, align: "right", render: (s) => formatBytes(Math.round(s.avgBytes)) },
|
|
1967
|
+
{ key: "bytesPerSecond", title: "Rate", width: 12, align: "right", render: (s) => formatRate(Math.round(s.bytesPerSecond)) }
|
|
1968
|
+
]
|
|
1969
|
+
});
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
function formatTimeMs(timestamp) {
|
|
1973
|
+
return `${formatTime(timestamp)}.${(timestamp % 1e3).toString().padStart(3, "0")}`;
|
|
1974
|
+
}
|
|
1975
|
+
function visiblePackets(packets, view) {
|
|
1976
|
+
return packets.filter((p) => (!view.eventsOnly || p.kind === "event") && (view.showPings || p.type !== "ping" && p.type !== "pong"));
|
|
1977
|
+
}
|
|
1978
|
+
function packetsPanel(ui, theme, state, view, transparent) {
|
|
1979
|
+
const rows = visiblePackets(state.packets, view).reverse();
|
|
1980
|
+
const anchor = view.packetTopId === null ? 0 : rows.findIndex((r) => r.id <= view.packetTopId);
|
|
1981
|
+
const offset = Math.max(0, anchor);
|
|
1982
|
+
const what = view.eventsOnly ? "Events" : "Packets and events";
|
|
1983
|
+
const title = `${what} (${rows.length}${view.showPings || view.eventsOnly ? "" : ", pings hidden"})${offset > 0 ? ", scroll up to follow" : ""}`;
|
|
1984
|
+
ui.panel({ title, size: "1fr" }, (panel) => {
|
|
1985
|
+
if (rows.length === 0) {
|
|
1986
|
+
panel.label(state.packetsAvailable === null ? "Waiting for the server\u2026" : view.eventsOnly ? "No events yet" : "No packets yet");
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
let top = offset;
|
|
1990
|
+
const narrow = panel.width < WIDE_LAYOUT;
|
|
1991
|
+
const directionColor = (p) => p.kind === "event" ? theme.warning : p.direction === "out" ? theme.graph[0] : theme.graph[1];
|
|
1992
|
+
panel.table({
|
|
1993
|
+
rows,
|
|
1994
|
+
offset,
|
|
1995
|
+
scrollbar: true,
|
|
1996
|
+
zebra: !transparent,
|
|
1997
|
+
size: "1fr",
|
|
1998
|
+
selected: rows.findIndex((p) => p.id === view.selectedPacketId),
|
|
1999
|
+
followSelection: view.selectedPacketId !== null && view.followSelectedPacket,
|
|
2000
|
+
// Following the selection can move the window, so the anchor goes by the row that was really drawn first
|
|
2001
|
+
onRow: (_row, index, y) => {
|
|
2002
|
+
if (y === 1) {
|
|
2003
|
+
top = index;
|
|
2004
|
+
view.packetTopId = index === 0 ? null : rows[index].id;
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
// A click opens the packet, a second one on it closes it again
|
|
2008
|
+
onSelectRow: (visibleRow) => {
|
|
2009
|
+
const row = rows[top + visibleRow];
|
|
2010
|
+
if (!row)
|
|
2011
|
+
return;
|
|
2012
|
+
selectPacket(view, row.id === view.selectedPacketId ? null : row.id);
|
|
2013
|
+
ui.ctx.invalidate();
|
|
2014
|
+
},
|
|
2015
|
+
onScroll: (delta) => {
|
|
2016
|
+
const next = Math.max(0, Math.min(rows.length - 1, top + delta));
|
|
2017
|
+
view.packetTopId = next === 0 ? null : rows[next].id;
|
|
2018
|
+
view.followSelectedPacket = false;
|
|
2019
|
+
ui.ctx.invalidate();
|
|
2020
|
+
},
|
|
2021
|
+
columns: [
|
|
2022
|
+
{ key: "time", title: "Time", width: 12, color: theme.muted, render: (p) => formatTimeMs(p.timestamp) },
|
|
2023
|
+
{ key: "direction", title: "", width: 3, color: directionColor, render: (p) => p.kind === "event" ? "\u2022" : p.direction === "out" ? "out" : "in" },
|
|
2024
|
+
{ key: "type", title: "Message", width: narrow ? 12 : 18, color: (p) => p.kind === "event" ? theme.warning : void 0 },
|
|
2025
|
+
{ key: "peer", title: "Client", width: narrow ? 8 : 26, color: theme.muted, render: (p) => p.peer ?? "\u2014" },
|
|
2026
|
+
{ key: "bytes", title: "Size", width: narrow ? 9 : 10, align: "right", render: (p) => p.kind === "packet" ? formatBytes(p.bytes) : "" },
|
|
2027
|
+
{ key: "info", title: "Details", render: describePacket }
|
|
2028
|
+
]
|
|
2029
|
+
});
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
function methodColor(method, theme) {
|
|
2033
|
+
if (method === "GET")
|
|
2034
|
+
return theme.success;
|
|
2035
|
+
if (method === "DELETE")
|
|
2036
|
+
return theme.danger;
|
|
2037
|
+
if (method === "ALL")
|
|
2038
|
+
return theme.muted;
|
|
2039
|
+
return theme.warning;
|
|
2040
|
+
}
|
|
2041
|
+
function routeSource(route) {
|
|
2042
|
+
return route.source === "plugin" ? route.plugin ?? "plugin" : route.source;
|
|
2043
|
+
}
|
|
2044
|
+
function routeEnvironment(route) {
|
|
2045
|
+
return route.devOnly ? "dev only" : "prod + dev";
|
|
2046
|
+
}
|
|
2047
|
+
function routesPanel(ui, theme, state, view, transparent) {
|
|
2048
|
+
const rows = state.routes;
|
|
2049
|
+
const maxOffset = Math.max(0, rows.length - 1);
|
|
2050
|
+
const offset = Math.min(view.routeOffset ?? 0, maxOffset);
|
|
2051
|
+
view.routeOffset = offset;
|
|
2052
|
+
const fromPlugins = rows.filter((r) => r.source === "plugin");
|
|
2053
|
+
const plugins = new Set(fromPlugins.map(routeSource)).size;
|
|
2054
|
+
const devOnly = rows.filter((r) => r.devOnly).length;
|
|
2055
|
+
const counts = [
|
|
2056
|
+
`${rows.filter((r) => r.source === "engine").length} engine`,
|
|
2057
|
+
`${rows.filter((r) => r.source === "game").length} game`,
|
|
2058
|
+
`${fromPlugins.length} from ${plugins} plugin${plugins === 1 ? "" : "s"}`,
|
|
2059
|
+
`${devOnly} dev only`
|
|
2060
|
+
];
|
|
2061
|
+
ui.panel({ title: `HTTP endpoints (${rows.length}: ${counts.join(", ")})`, size: "1fr" }, (panel) => {
|
|
2062
|
+
if (state.routesAvailable === false) {
|
|
2063
|
+
panel.label("Not available: the server runs a @mavonengine/core release without GET /api/game/routes");
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (rows.length === 0) {
|
|
2067
|
+
panel.label(state.routesAvailable === null ? "Asking the server for its endpoints\u2026" : "The server has no endpoints");
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
panel.table({
|
|
2071
|
+
rows,
|
|
2072
|
+
offset,
|
|
2073
|
+
scrollbar: true,
|
|
2074
|
+
// Zebra stripes are solid fills, which would break transparency
|
|
2075
|
+
zebra: !transparent,
|
|
2076
|
+
size: "1fr",
|
|
2077
|
+
// The table won't scroll past the last full page, so keep our offset on the row it actually drew
|
|
2078
|
+
onRow: (_row, index, y) => {
|
|
2079
|
+
if (y === 1)
|
|
2080
|
+
view.routeOffset = index;
|
|
2081
|
+
},
|
|
2082
|
+
onScroll: (delta) => {
|
|
2083
|
+
view.routeOffset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2084
|
+
ui.ctx.invalidate();
|
|
2085
|
+
},
|
|
2086
|
+
columns: [
|
|
2087
|
+
{ key: "method", title: "Method", width: 7, color: (r) => methodColor(r.method, theme) },
|
|
2088
|
+
{ key: "path", title: "Path" },
|
|
2089
|
+
{ key: "devOnly", title: "Served in", width: 10, color: (r) => r.devOnly ? theme.warning : theme.success, render: routeEnvironment },
|
|
2090
|
+
{ key: "source", title: "From", width: 30, color: (r) => r.source === "engine" ? theme.muted : r.source === "plugin" ? theme.primary : void 0, render: routeSource },
|
|
2091
|
+
{ key: "summary", title: "What it does", color: theme.muted, render: (r) => r.summary ?? "" }
|
|
2092
|
+
]
|
|
2093
|
+
});
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
var PLUGIN_TARGETS = { client: "client", server: "server", both: "client + server" };
|
|
2097
|
+
function pluginStatus(plugin, theme) {
|
|
2098
|
+
if (plugin.problem)
|
|
2099
|
+
return { label: "problem", color: theme.danger };
|
|
2100
|
+
if (!plugin.enabled)
|
|
2101
|
+
return { label: "disabled", color: theme.muted };
|
|
2102
|
+
if ((plugin.target === "server" || plugin.target === "both") && !plugin.runningOnServer)
|
|
2103
|
+
return { label: "not loaded", color: theme.warning };
|
|
2104
|
+
return { label: "enabled", color: theme.success };
|
|
2105
|
+
}
|
|
2106
|
+
function pluginsTab(ui, theme, state, view, transparent, available, installer) {
|
|
2107
|
+
const { plugins } = state;
|
|
2108
|
+
void available?.load();
|
|
2109
|
+
const rest = installable(plugins, available);
|
|
2110
|
+
const shown = selectedPlugin(view, plugins, rest);
|
|
2111
|
+
const endpoints = (plugin) => state.routes.filter((r) => r.source === "plugin" && r.plugin === plugin.package);
|
|
2112
|
+
const confirm = rest.find((p) => p.package === view.confirmInstall);
|
|
2113
|
+
if (!confirm)
|
|
2114
|
+
view.confirmInstall = null;
|
|
2115
|
+
if (state.pluginsAvailable === false || plugins.length === 0) {
|
|
2116
|
+
ui.panel({ title: "Plugins", size: available ? 3 : "1fr" }, (panel) => {
|
|
2117
|
+
if (state.pluginsAvailable === false)
|
|
2118
|
+
panel.label("Not available: the server runs a @mavonengine/core release without GET /api/game/plugins");
|
|
2119
|
+
else
|
|
2120
|
+
panel.label(state.pluginsAvailable === null ? "Asking the server for its plugins\u2026" : "The project has no plugins. Add one with: mavonengine install <package>");
|
|
2121
|
+
});
|
|
2122
|
+
} else {
|
|
2123
|
+
let top = 0;
|
|
2124
|
+
ui.panel({ title: `Plugins (${plugins.length})`, size: Math.min(plugins.length, 8) + 3 }, (panel) => {
|
|
2125
|
+
panel.table({
|
|
2126
|
+
rows: plugins,
|
|
2127
|
+
selected: shown.installed ? plugins.indexOf(shown.installed) : -1,
|
|
2128
|
+
followSelection: true,
|
|
2129
|
+
// With more plugins than rows the table scrolls to the selection, so a click counts from the row drawn first
|
|
2130
|
+
onRow: (_row, index, y) => {
|
|
2131
|
+
if (y === 1)
|
|
2132
|
+
top = index;
|
|
2133
|
+
},
|
|
2134
|
+
onSelectRow: (visibleRow) => {
|
|
2135
|
+
const row = plugins[top + visibleRow];
|
|
2136
|
+
if (!row || view.confirmInstall)
|
|
2137
|
+
return;
|
|
2138
|
+
view.selectedPlugin = row.package;
|
|
2139
|
+
ui.ctx.invalidate();
|
|
2140
|
+
},
|
|
2141
|
+
scrollbar: true,
|
|
2142
|
+
// Zebra stripes are solid fills, which would break transparency
|
|
2143
|
+
zebra: !transparent,
|
|
2144
|
+
size: "1fr",
|
|
2145
|
+
columns: [
|
|
2146
|
+
{ key: "package", title: "Package" },
|
|
2147
|
+
{ key: "version", title: "Version", width: 8, render: (p) => p.version ?? "\u2014" },
|
|
2148
|
+
{ key: "status", title: "Status", width: 10, color: (p) => pluginStatus(p, theme).color, render: (p) => pluginStatus(p, theme).label },
|
|
2149
|
+
{ key: "target", title: "Runs on", width: 7, render: (p) => p.target ?? "\u2014" },
|
|
2150
|
+
{ key: "endpoints", title: "Routes", width: 6, align: "right", render: (p) => state.routesAvailable ? String(endpoints(p).length) : "\u2014" },
|
|
2151
|
+
{ key: "prefabs", title: "Prefabs", width: 7, align: "right", render: (p) => String((/* @__PURE__ */ new Set([...p.declaredPrefabs, ...p.usedPrefabs])).size) }
|
|
2152
|
+
]
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
if (installer?.job)
|
|
2157
|
+
installPanel(ui, theme, installer.job);
|
|
2158
|
+
if (confirm)
|
|
2159
|
+
confirmInstallPanel(ui, theme, confirm);
|
|
2160
|
+
else if (shown.installed)
|
|
2161
|
+
pluginDetails(ui, theme, state, shown.installed, endpoints(shown.installed), "1fr", transparent);
|
|
2162
|
+
else if (shown.available)
|
|
2163
|
+
availableDetails(ui, theme, shown.available, installer);
|
|
2164
|
+
if (available)
|
|
2165
|
+
availablePanel(ui, theme, available, rest, shown.available, view, shown.installed || confirm || shown.available ? Math.min(Math.max(1, rest.length), 6) + 3 : "1fr", transparent);
|
|
2166
|
+
}
|
|
2167
|
+
var INSTALL_OUTPUT_LINES = 6;
|
|
2168
|
+
var INSTALL_STATUS = {
|
|
2169
|
+
installing: "Installing",
|
|
2170
|
+
restarting: "Installed, restarting the dev servers for",
|
|
2171
|
+
done: "Installed",
|
|
2172
|
+
failed: "Could not install"
|
|
2173
|
+
};
|
|
2174
|
+
function installPanel(ui, theme, job) {
|
|
2175
|
+
const lines = stripVTControlCharacters(job.output).split(NEWLINE_RE).filter((line) => line.trim() !== "");
|
|
2176
|
+
const color = job.status === "failed" ? theme.danger : job.status === "done" ? theme.success : theme.warning;
|
|
2177
|
+
ui.panel({ title: `${INSTALL_STATUS[job.status]} ${job.packages.join(", ")}`, size: INSTALL_OUTPUT_LINES + 3 }, (panel) => {
|
|
2178
|
+
if (job.status === "failed")
|
|
2179
|
+
panel.text([{ text: job.error ?? "The install failed", fg: color }], { size: 1 });
|
|
2180
|
+
else if (job.status === "done")
|
|
2181
|
+
panel.text([{ text: "The dev servers were restarted with it", fg: color }], { size: 1 });
|
|
2182
|
+
else
|
|
2183
|
+
panel.spinner({ label: job.status === "restarting" ? "Stopping and starting the dev script" : "Running the package manager", size: 1 });
|
|
2184
|
+
panel.log({ entries: lines.slice(-INSTALL_OUTPUT_LINES).map((message) => ({ message })), size: "1fr" });
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
function confirmInstallPanel(ui, theme, plugin) {
|
|
2188
|
+
ui.panel({ title: `Install ${plugin.package}?`, size: "1fr" }, (panel) => {
|
|
2189
|
+
panel.text([{ text: "Installing a package runs its code on this machine.", fg: theme.warning, bold: true }], { wrap: true });
|
|
2190
|
+
panel.text("Its install scripts run right away, and the plugin runs inside your game and game server from then on, with everything they can reach. The list of plugins is not a review of what is on it.", { wrap: true });
|
|
2191
|
+
panel.spacer(1);
|
|
2192
|
+
panel.text("Check the source before you install it:", { wrap: true });
|
|
2193
|
+
panel.text([{ text: ` ${plugin.homepage ?? `https://www.npmjs.com/package/${plugin.package}`}`, fg: theme.primary }], { wrap: true });
|
|
2194
|
+
panel.spacer(1);
|
|
2195
|
+
panel.text(`It is installed with the project's package manager, added to mavonengine.json, and the dev servers are restarted.`, { wrap: true });
|
|
2196
|
+
panel.spacer(1);
|
|
2197
|
+
panel.text([
|
|
2198
|
+
{ text: " y ", fg: theme.background, bg: theme.warning, bold: true },
|
|
2199
|
+
{ text: " I checked the source, install it " },
|
|
2200
|
+
{ text: " n ", fg: theme.background, bg: theme.muted, bold: true },
|
|
2201
|
+
{ text: " Cancel" }
|
|
2202
|
+
], { size: 1 });
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
function availableDetails(ui, theme, plugin, installer) {
|
|
2206
|
+
ui.panel({ title: plugin.name === plugin.package ? plugin.package : `${plugin.name} (${plugin.package})`, size: "1fr" }, (panel) => {
|
|
2207
|
+
if (plugin.description)
|
|
2208
|
+
panel.text(plugin.description, { wrap: true });
|
|
2209
|
+
panel.keyValues([
|
|
2210
|
+
{ label: "Status", value: "not installed", color: theme.muted },
|
|
2211
|
+
{ label: "Runs on", value: plugin.target ? PLUGIN_TARGETS[plugin.target] : "\u2014" },
|
|
2212
|
+
{ label: "Engine", value: plugin.engine ? `@mavonengine/core ${plugin.engine}` : "\u2014" },
|
|
2213
|
+
{ label: "Source", value: plugin.homepage ?? `https://www.npmjs.com/package/${plugin.package}`, color: theme.primary }
|
|
2214
|
+
], { spread: true });
|
|
2215
|
+
panel.spacer(1);
|
|
2216
|
+
if (!installer)
|
|
2217
|
+
panel.label(`Install it with: mavonengine install ${plugin.package}`);
|
|
2218
|
+
else if (installer.busy)
|
|
2219
|
+
panel.label("Another install is running");
|
|
2220
|
+
else
|
|
2221
|
+
panel.text([{ text: "Press " }, { text: "i", fg: theme.primary, bold: true }, { text: " to install it and restart the dev servers" }], { size: 1 });
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
function availablePanel(ui, theme, available, rows, selected, view, size, transparent) {
|
|
2225
|
+
const { status, error, plugins } = available.state;
|
|
2226
|
+
const loaded = status === "ready" || plugins.length > 0;
|
|
2227
|
+
let top = 0;
|
|
2228
|
+
ui.panel({ title: loaded ? `Available to install (${rows.length})` : "Available to install", size }, (panel) => {
|
|
2229
|
+
if (error) {
|
|
2230
|
+
panel.text([{ text: `Could not read the list of plugins: ${error}`, fg: theme.danger }], { wrap: true });
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (!loaded) {
|
|
2234
|
+
panel.spinner({ label: "Reading the list of plugins from GitHub" });
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (rows.length === 0) {
|
|
2238
|
+
panel.label(plugins.length ? "The project has every plugin on the list" : "The list of plugins is empty");
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
panel.table({
|
|
2242
|
+
rows,
|
|
2243
|
+
selected: selected ? rows.indexOf(selected) : -1,
|
|
2244
|
+
followSelection: true,
|
|
2245
|
+
onRow: (_row, index, y) => {
|
|
2246
|
+
if (y === 1)
|
|
2247
|
+
top = index;
|
|
2248
|
+
},
|
|
2249
|
+
onSelectRow: (visibleRow) => {
|
|
2250
|
+
const row = rows[top + visibleRow];
|
|
2251
|
+
if (!row || view.confirmInstall)
|
|
2252
|
+
return;
|
|
2253
|
+
view.selectedPlugin = row.package;
|
|
2254
|
+
ui.ctx.invalidate();
|
|
2255
|
+
},
|
|
2256
|
+
scrollbar: true,
|
|
2257
|
+
zebra: !transparent,
|
|
2258
|
+
size: "1fr",
|
|
2259
|
+
columns: [
|
|
2260
|
+
{ key: "package", title: "Package", color: theme.primary },
|
|
2261
|
+
{ key: "target", title: "Runs on", width: 7, render: (p) => p.target ?? "\u2014" },
|
|
2262
|
+
{ key: "description", title: "What it is", color: theme.muted, render: (p) => p.description ?? (p.name === p.package ? "" : p.name) }
|
|
2263
|
+
]
|
|
2264
|
+
});
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
function pluginDetails(ui, theme, state, plugin, endpoints, size, transparent) {
|
|
2268
|
+
const status = pluginStatus(plugin, theme);
|
|
2269
|
+
const prefabs = [.../* @__PURE__ */ new Set([...plugin.declaredPrefabs, ...plugin.usedPrefabs])];
|
|
2270
|
+
ui.panel({ title: plugin.package, size }, (panel) => {
|
|
2271
|
+
if (plugin.description)
|
|
2272
|
+
panel.text(plugin.description, { wrap: true });
|
|
2273
|
+
if (plugin.problem)
|
|
2274
|
+
panel.text([{ text: plugin.problem, fg: theme.danger }], { wrap: true });
|
|
2275
|
+
panel.keyValues([
|
|
2276
|
+
{ label: "Version", value: plugin.version ?? "\u2014" },
|
|
2277
|
+
{ label: "Status", value: status.label, color: status.color },
|
|
2278
|
+
{ label: "Runs on", value: plugin.target ? PLUGIN_TARGETS[plugin.target] : "\u2014" },
|
|
2279
|
+
// A client plugin has nothing to run here
|
|
2280
|
+
plugin.target === "client" ? { label: "On this server", value: "nothing to run", color: theme.muted } : { label: "On this server", value: plugin.runningOnServer ? "running" : "not running", color: plugin.runningOnServer ? theme.success : theme.warning },
|
|
2281
|
+
{ label: "Engine", value: plugin.engine ? `@mavonengine/core ${plugin.engine}` : "\u2014" },
|
|
2282
|
+
{ label: "License", value: plugin.license ?? "\u2014" },
|
|
2283
|
+
// Used ones ship their assets in a release
|
|
2284
|
+
{ label: "Prefabs", value: prefabs.length ? prefabs.map((name) => plugin.usedPrefabs.includes(name) ? `${name} (used)` : name).join(", ") : "none" }
|
|
2285
|
+
], { spread: true });
|
|
2286
|
+
panel.spacer(1);
|
|
2287
|
+
if (state.routesAvailable !== true) {
|
|
2288
|
+
panel.label("HTTP endpoints: unknown, the server doesn't list its endpoints");
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
if (endpoints.length === 0) {
|
|
2292
|
+
panel.label("HTTP endpoints: none");
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
panel.label(`HTTP endpoints (${endpoints.length})`);
|
|
2296
|
+
panel.table({
|
|
2297
|
+
rows: endpoints,
|
|
2298
|
+
scrollbar: true,
|
|
2299
|
+
zebra: !transparent,
|
|
2300
|
+
size: "1fr",
|
|
2301
|
+
columns: [
|
|
2302
|
+
{ key: "method", title: "Method", width: 7, color: (r) => methodColor(r.method, theme) },
|
|
2303
|
+
{ key: "path", title: "Path" },
|
|
2304
|
+
{ key: "devOnly", title: "Served in", width: 10, color: (r) => r.devOnly ? theme.warning : theme.success, render: routeEnvironment }
|
|
2305
|
+
]
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// src/commands/dashboard.ts
|
|
2311
|
+
var DASHBOARD_HELP = `Usage: mavonengine dashboard [options]
|
|
2312
|
+
|
|
2313
|
+
Live terminal dashboard for a running MavonEngine game server.
|
|
2314
|
+
|
|
2315
|
+
Options:
|
|
2316
|
+
--host <host> Server host (default: localhost)
|
|
2317
|
+
--port <port> Server HTTP port (default: 8050)
|
|
2318
|
+
--url <url> Full metrics URL, overrides --host and --port
|
|
2319
|
+
--token <token> Metrics or admin token (or set MAVONENGINE_METRICS_TOKEN
|
|
2320
|
+
or MAVONENGINE_ADMIN_TOKEN)
|
|
2321
|
+
--interval <ms> Poll interval in ms (default: 1000)
|
|
2322
|
+
--theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix, light, \u2026
|
|
2323
|
+
--transparent Let a transparent terminal background show through
|
|
2324
|
+
(text can be hard to read over a light wallpaper)
|
|
2325
|
+
--sim Show simulated data instead of connecting to a server
|
|
2326
|
+
-h, --help Show this help
|
|
2327
|
+
`;
|
|
2328
|
+
var STARTUP_POLL_MS = 250;
|
|
2329
|
+
var UsageError = class extends Error {
|
|
2330
|
+
};
|
|
2331
|
+
function parseDashboardArgs(args, env = process3.env) {
|
|
2332
|
+
const { values } = parseArgs({
|
|
2333
|
+
args,
|
|
2334
|
+
options: {
|
|
2335
|
+
host: { type: "string", default: "localhost" },
|
|
2336
|
+
port: { type: "string", default: "8050" },
|
|
2337
|
+
url: { type: "string" },
|
|
2338
|
+
token: { type: "string" },
|
|
2339
|
+
interval: { type: "string", default: "1000" },
|
|
2340
|
+
theme: { type: "string" },
|
|
2341
|
+
transparent: { type: "boolean", default: false },
|
|
2342
|
+
sim: { type: "boolean", default: false },
|
|
2343
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2344
|
+
},
|
|
2345
|
+
strict: true,
|
|
2346
|
+
allowPositionals: false
|
|
2347
|
+
});
|
|
2348
|
+
if (values.help)
|
|
2349
|
+
return "help";
|
|
2350
|
+
const intervalMs = Number(values.interval);
|
|
2351
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 100)
|
|
2352
|
+
throw new UsageError("--interval must be a whole number of ms, at least 100");
|
|
2353
|
+
if (values.sim)
|
|
2354
|
+
return { source: simulatedSource(), intervalMs, theme: values.theme, transparent: values.transparent };
|
|
2355
|
+
let url = values.url;
|
|
2356
|
+
if (!url) {
|
|
2357
|
+
const port = Number(values.port);
|
|
2358
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
2359
|
+
throw new UsageError("--port must be between 1 and 65535");
|
|
2360
|
+
const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
|
|
2361
|
+
url = `http://${host}:${port}/api/game/metrics`;
|
|
2362
|
+
}
|
|
2363
|
+
try {
|
|
2364
|
+
void new URL(url);
|
|
2365
|
+
} catch {
|
|
2366
|
+
throw new UsageError(`Invalid --url: ${url}`);
|
|
2367
|
+
}
|
|
2368
|
+
const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN ?? env.MAVONENGINE_ADMIN_TOKEN;
|
|
2369
|
+
return { source: httpSource({ url, token }), clientUrl: new URL("/", url).href, intervalMs, theme: values.theme, transparent: values.transparent };
|
|
2370
|
+
}
|
|
2371
|
+
async function runDashboard(options, signal) {
|
|
2372
|
+
const { createApp } = await import("@profullstack/hqtui");
|
|
2373
|
+
const client = new MetricsClient(options.source, {
|
|
2374
|
+
intervalMs: options.intervalMs,
|
|
2375
|
+
startupIntervalMs: options.startup ? STARTUP_POLL_MS : void 0
|
|
2376
|
+
});
|
|
2377
|
+
const view = initialViewState();
|
|
2378
|
+
const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent });
|
|
2379
|
+
const available = new AvailablePlugins();
|
|
2380
|
+
available.onChange(() => app.invalidate());
|
|
2381
|
+
const { installer } = options;
|
|
2382
|
+
installer?.onChange((job) => {
|
|
2383
|
+
app.invalidate();
|
|
2384
|
+
if (job.status === "done")
|
|
2385
|
+
void client.refresh();
|
|
2386
|
+
});
|
|
2387
|
+
const installable2 = () => notInProject(available.state.plugins, client.state.plugins.map((plugin) => plugin.package));
|
|
2388
|
+
app.render(({ ui, theme }) => {
|
|
2389
|
+
renderDashboard(ui, theme, client.state, view, {
|
|
2390
|
+
available,
|
|
2391
|
+
installer,
|
|
2392
|
+
transparent: options.transparent,
|
|
2393
|
+
startup: options.startup?.(),
|
|
2394
|
+
openKey: !!options.clientUrl
|
|
2395
|
+
});
|
|
2396
|
+
const watch = showsNetwork(view);
|
|
2397
|
+
if (watch && !client.watchNetwork) {
|
|
2398
|
+
client.watchNetwork = true;
|
|
2399
|
+
void client.refresh();
|
|
2400
|
+
}
|
|
2401
|
+
client.watchNetwork = watch;
|
|
2402
|
+
if ((client.state.packetDetail?.id ?? null) !== view.selectedPacketId)
|
|
2403
|
+
void client.inspectPacket(view.selectedPacketId);
|
|
2404
|
+
});
|
|
2405
|
+
client.onChange(() => app.invalidate());
|
|
2406
|
+
const { clientUrl } = options;
|
|
2407
|
+
if (clientUrl && options.openBrowser) {
|
|
2408
|
+
const off = client.onChange(async (state) => {
|
|
2409
|
+
if (state.status !== "live")
|
|
2410
|
+
return;
|
|
2411
|
+
off();
|
|
2412
|
+
if (await servesPage(clientUrl))
|
|
2413
|
+
openBrowser(clientUrl);
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
app.on("key", (event) => {
|
|
2417
|
+
const install = handleInstallKey(view, client.state.plugins, event.key, installable2(), installer);
|
|
2418
|
+
if (install) {
|
|
2419
|
+
if (typeof install === "string")
|
|
2420
|
+
installer.start([install]);
|
|
2421
|
+
app.invalidate();
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (event.key === "r") {
|
|
2425
|
+
void client.refresh();
|
|
2426
|
+
if (available.state.status !== "idle")
|
|
2427
|
+
void available.load(true);
|
|
2428
|
+
}
|
|
2429
|
+
if (event.key === "o" && options.clientUrl)
|
|
2430
|
+
openBrowser(options.clientUrl);
|
|
2431
|
+
const tab = tabForKey(event.key);
|
|
2432
|
+
if (tab !== null) {
|
|
2433
|
+
view.tab = tab;
|
|
2434
|
+
app.invalidate();
|
|
2435
|
+
}
|
|
2436
|
+
if (handlePlayersKey(view, client.state.snapshot, event.key) || handleNetworkKey(view, event.key, client.state.packets) || handlePluginsKey(view, client.state.plugins, event.key, installable2()))
|
|
2437
|
+
app.invalidate();
|
|
2438
|
+
});
|
|
2439
|
+
app.on("exit", () => client.stop());
|
|
2440
|
+
const onAbort = () => app.stop();
|
|
2441
|
+
signal?.addEventListener("abort", onAbort);
|
|
2442
|
+
client.start();
|
|
2443
|
+
if (!signal?.aborted)
|
|
2444
|
+
await app.start();
|
|
2445
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2446
|
+
client.stop();
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// src/commands/dev.ts
|
|
2450
|
+
import { execFileSync, spawn as spawn3 } from "child_process";
|
|
2451
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
2452
|
+
import { constants } from "os";
|
|
2453
|
+
import { dirname as dirname3, join as join4, resolve as resolve4 } from "path";
|
|
2454
|
+
import process6 from "process";
|
|
2455
|
+
import { setTimeout as delay } from "timers/promises";
|
|
2456
|
+
import { parseArgs as parseArgs3 } from "util";
|
|
2457
|
+
|
|
2458
|
+
// src/dev/control.ts
|
|
2459
|
+
import { Buffer } from "buffer";
|
|
2460
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
2461
|
+
import { createServer } from "http";
|
|
2462
|
+
|
|
2463
|
+
// ../core/dist/Plugins/install.js
|
|
2464
|
+
var DEV_CONTROL_URL_ENV = "MAVONENGINE_DEV_CONTROL_URL";
|
|
2465
|
+
var DEV_CONTROL_TOKEN_ENV = "MAVONENGINE_DEV_CONTROL_TOKEN";
|
|
2466
|
+
var DEV_CONTROL_TOKEN_HEADER = "x-mavonengine-dev-token";
|
|
2467
|
+
var MAX_INSTALL_PACKAGES = 10;
|
|
2468
|
+
var MAX_SPEC_LENGTH = 300;
|
|
2469
|
+
var PACKAGE_SPEC_RE = /^[\w@./~][\w@./:~=+#-]*$/;
|
|
2470
|
+
function invalidPackages(packages) {
|
|
2471
|
+
if (!Array.isArray(packages) || packages.length === 0)
|
|
2472
|
+
return "Expected { packages: [...] } with at least one package";
|
|
2473
|
+
if (packages.length > MAX_INSTALL_PACKAGES)
|
|
2474
|
+
return `At most ${MAX_INSTALL_PACKAGES} packages at a time`;
|
|
2475
|
+
for (const spec of packages) {
|
|
2476
|
+
if (typeof spec !== "string" || spec.length > MAX_SPEC_LENGTH || !PACKAGE_SPEC_RE.test(spec))
|
|
2477
|
+
return `${JSON.stringify(spec)} is not something that can be installed from here: use a package name, name@version, a file: path or a URL, without spaces or version ranges like ^1.0.0`;
|
|
2478
|
+
}
|
|
2479
|
+
return void 0;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// src/commands/install.ts
|
|
2483
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
2484
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
2485
|
+
import process5 from "process";
|
|
2486
|
+
import { createInterface } from "readline/promises";
|
|
2487
|
+
import { parseArgs as parseArgs2 } from "util";
|
|
2488
|
+
|
|
2489
|
+
// src/create/project.ts
|
|
2490
|
+
import { spawn as spawn2 } from "child_process";
|
|
2491
|
+
import { createWriteStream, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2492
|
+
import { tmpdir } from "os";
|
|
2493
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2 } from "path";
|
|
2494
|
+
import process4 from "process";
|
|
2495
|
+
import { Readable, Transform } from "stream";
|
|
2496
|
+
import { pipeline } from "stream/promises";
|
|
2497
|
+
import AdmZip from "adm-zip";
|
|
2498
|
+
var TEMPLATE_URL = "https://github.com/MavonEngine/Core/archive/refs/tags/0.0.17-alpha.zip";
|
|
2499
|
+
var TEMPLATE_SUBPATH = "packages/multiplayer-template";
|
|
2500
|
+
var PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/;
|
|
2501
|
+
function projectDirFor(projectName, parentDir) {
|
|
2502
|
+
return resolve2(parentDir, projectName);
|
|
2503
|
+
}
|
|
2504
|
+
async function downloadZip(url, { report, signal, fetch = globalThis.fetch }) {
|
|
2505
|
+
const response = await fetch(url, { signal });
|
|
2506
|
+
if (!response.ok || !response.body)
|
|
2507
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
2508
|
+
const total = Number(response.headers.get("content-length")) || 0;
|
|
2509
|
+
let received = 0;
|
|
2510
|
+
const progress = new Transform({
|
|
2511
|
+
transform(chunk, _encoding, callback) {
|
|
2512
|
+
received += chunk.length;
|
|
2513
|
+
report.step(`Downloading the template\u2026 ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ""}`);
|
|
2514
|
+
callback(null, chunk);
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
const dest = join2(tmpdir(), `mavonengine-create-${process4.pid}-${Date.now()}.zip`);
|
|
2518
|
+
try {
|
|
2519
|
+
await pipeline(Readable.fromWeb(response.body), progress, createWriteStream(dest), { signal });
|
|
2520
|
+
} catch (err) {
|
|
2521
|
+
rmSync(dest, { force: true });
|
|
2522
|
+
throw err;
|
|
2523
|
+
}
|
|
2524
|
+
return dest;
|
|
2525
|
+
}
|
|
2526
|
+
function extractTemplate(zipPath, templateSubpath, destDir) {
|
|
2527
|
+
const entries = new AdmZip(zipPath).getEntries();
|
|
2528
|
+
if (entries.length === 0)
|
|
2529
|
+
throw new Error("Downloaded zip archive is empty");
|
|
2530
|
+
const rootFolder = entries[0].entryName.split("/")[0];
|
|
2531
|
+
const templatePrefix = `${rootFolder}/${templateSubpath}/`;
|
|
2532
|
+
const templateEntries = entries.filter((e) => e.entryName.startsWith(templatePrefix));
|
|
2533
|
+
if (templateEntries.length === 0)
|
|
2534
|
+
throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`);
|
|
2535
|
+
const root = resolve2(destDir);
|
|
2536
|
+
let files = 0;
|
|
2537
|
+
for (const entry of templateEntries) {
|
|
2538
|
+
const relativePath = entry.entryName.slice(templatePrefix.length);
|
|
2539
|
+
if (!relativePath)
|
|
2540
|
+
continue;
|
|
2541
|
+
const outputPath = resolve2(root, relativePath);
|
|
2542
|
+
const inside = relative2(root, outputPath);
|
|
2543
|
+
if (inside.startsWith("..") || isAbsolute2(inside))
|
|
2544
|
+
throw new Error(`Refusing to extract ${entry.entryName} outside the project`);
|
|
2545
|
+
if (entry.isDirectory) {
|
|
2546
|
+
mkdirSync(outputPath, { recursive: true });
|
|
2547
|
+
} else {
|
|
2548
|
+
mkdirSync(dirname2(outputPath), { recursive: true });
|
|
2549
|
+
writeFileSync2(outputPath, entry.getData());
|
|
2550
|
+
files++;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return files;
|
|
2554
|
+
}
|
|
2555
|
+
function runCommand(command, args, cwd, { report, signal }) {
|
|
2556
|
+
return new Promise((resolvePromise, reject) => {
|
|
2557
|
+
const child = spawn2(command, args, {
|
|
2558
|
+
cwd,
|
|
2559
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2560
|
+
// npm is a .cmd shim on Windows
|
|
2561
|
+
shell: process4.platform === "win32",
|
|
2562
|
+
signal
|
|
2563
|
+
});
|
|
2564
|
+
child.stdout.setEncoding("utf8").on("data", report.output);
|
|
2565
|
+
child.stderr.setEncoding("utf8").on("data", report.output);
|
|
2566
|
+
child.on("error", (err) => {
|
|
2567
|
+
reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
|
|
2568
|
+
});
|
|
2569
|
+
child.on("exit", (code, exitSignal) => {
|
|
2570
|
+
if (code === 0)
|
|
2571
|
+
resolvePromise();
|
|
2572
|
+
else
|
|
2573
|
+
reject(new Error(`'${command} ${args.join(" ")}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`));
|
|
2574
|
+
});
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }) {
|
|
2578
|
+
const projectDir = projectDirFor(projectName, parentDir);
|
|
2579
|
+
if (existsSync2(projectDir))
|
|
2580
|
+
throw new Error(`Directory "${projectDir}" already exists`);
|
|
2581
|
+
report.output(`Creating project "${projectName}" in ${projectDir}
|
|
2582
|
+
`);
|
|
2583
|
+
mkdirSync(projectDir, { recursive: true });
|
|
2584
|
+
report.step("Downloading the template\u2026");
|
|
2585
|
+
report.output(`Downloading the template from ${templateUrl}
|
|
2586
|
+
`);
|
|
2587
|
+
const zipPath = await downloadZip(templateUrl, { report, signal, fetch });
|
|
2588
|
+
try {
|
|
2589
|
+
report.step("Extracting the template\u2026");
|
|
2590
|
+
const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir);
|
|
2591
|
+
report.output(`Extracted ${files} files
|
|
2592
|
+
`);
|
|
2593
|
+
} finally {
|
|
2594
|
+
rmSync(zipPath, { force: true });
|
|
2595
|
+
}
|
|
2596
|
+
const pkgJsonPath = join2(projectDir, "package.json");
|
|
2597
|
+
if (existsSync2(pkgJsonPath)) {
|
|
2598
|
+
const pkg = JSON.parse(readFileSync2(pkgJsonPath, "utf-8"));
|
|
2599
|
+
pkg.name = projectName;
|
|
2600
|
+
writeFileSync2(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}
|
|
2601
|
+
`);
|
|
2602
|
+
}
|
|
2603
|
+
if (writeMcpConfig(projectDir))
|
|
2604
|
+
report.output(`Wrote ${MCP_CONFIG_FILE} so AI assistants with MCP support can inspect the running game server
|
|
2605
|
+
`);
|
|
2606
|
+
if (install) {
|
|
2607
|
+
signal?.throwIfAborted();
|
|
2608
|
+
report.step("Installing dependencies\u2026");
|
|
2609
|
+
report.output("Running 'npm install'\n");
|
|
2610
|
+
await runCommand("npm", ["install"], projectDir, { report, signal });
|
|
2611
|
+
}
|
|
2612
|
+
return projectDir;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// src/commands/install.ts
|
|
2616
|
+
var INSTALL_HELP = `Usage: mavonengine install <package...> [options]
|
|
2617
|
+
|
|
2618
|
+
Install packages into the MavonEngine project you are in. For each one that is a
|
|
2619
|
+
MavonEngine plugin, asks whether to add it to the plugins in ${CONFIG_FILE}, so it
|
|
2620
|
+
loads on the client and/or the game server and is built into the release.
|
|
2621
|
+
|
|
2622
|
+
Takes anything your package manager does: names, name@version, file: paths, git URLs.
|
|
2623
|
+
|
|
2624
|
+
Options:
|
|
2625
|
+
--cwd <dir> Start looking for the project here (default: current directory)
|
|
2626
|
+
-y, --yes Add plugins to ${CONFIG_FILE} without asking
|
|
2627
|
+
--no-register Only install, leave ${CONFIG_FILE} alone
|
|
2628
|
+
-h, --help Show this help
|
|
2629
|
+
`;
|
|
2630
|
+
var PACKAGE_SPEC_RE2 = /^((?:@[\w.-]+\/)?[\w.-]+)(?:@.*)?$/;
|
|
2631
|
+
var LOCAL_PATH_RE = /^(?:\.{1,2}(?:[/\\]|$)|[/~]|[a-z]:[/\\])/i;
|
|
2632
|
+
var TARGET_LABELS = {
|
|
2633
|
+
client: "client only",
|
|
2634
|
+
server: "server only",
|
|
2635
|
+
both: "client + server"
|
|
2636
|
+
};
|
|
2637
|
+
function parseInstallArgs(args, cwd = process5.cwd()) {
|
|
2638
|
+
const { values, positionals } = parseArgs2({
|
|
2639
|
+
args,
|
|
2640
|
+
options: {
|
|
2641
|
+
cwd: { type: "string" },
|
|
2642
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
2643
|
+
register: { type: "boolean", default: true },
|
|
2644
|
+
help: { type: "boolean", short: "h", default: false }
|
|
2645
|
+
},
|
|
2646
|
+
strict: true,
|
|
2647
|
+
allowPositionals: true,
|
|
2648
|
+
allowNegative: true
|
|
2649
|
+
});
|
|
2650
|
+
if (values.help)
|
|
2651
|
+
return "help";
|
|
2652
|
+
if (positionals.length === 0)
|
|
2653
|
+
throw new UsageError(`which package? e.g. mavonengine install my-plugin
|
|
2654
|
+
|
|
2655
|
+
${INSTALL_HELP}`);
|
|
2656
|
+
return { cwd: resolve3(cwd, values.cwd ?? "."), packages: positionals, yes: values.yes, register: values.register };
|
|
2657
|
+
}
|
|
2658
|
+
function packageNameFromSpec(spec, root) {
|
|
2659
|
+
const localPath = spec.startsWith("file:") ? spec.slice("file:".length) : LOCAL_PATH_RE.test(spec) ? spec : void 0;
|
|
2660
|
+
if (localPath !== void 0) {
|
|
2661
|
+
const packageJson = join3(resolve3(root, localPath), "package.json");
|
|
2662
|
+
try {
|
|
2663
|
+
return existsSync3(packageJson) ? JSON.parse(readFileSync3(packageJson, "utf8")).name : void 0;
|
|
2664
|
+
} catch {
|
|
2665
|
+
return void 0;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
if (spec.includes(":") || spec.endsWith(".tgz"))
|
|
2669
|
+
return void 0;
|
|
2670
|
+
return PACKAGE_SPEC_RE2.exec(spec)?.[1];
|
|
2671
|
+
}
|
|
2672
|
+
function installArgs(pm, packages) {
|
|
2673
|
+
return [pm === "npm" ? "install" : "add", ...packages];
|
|
2674
|
+
}
|
|
2675
|
+
function dependencyNames(root) {
|
|
2676
|
+
const path = join3(root, "package.json");
|
|
2677
|
+
if (!existsSync3(path))
|
|
2678
|
+
return /* @__PURE__ */ new Map();
|
|
2679
|
+
const pkg = JSON.parse(readFileSync3(path, "utf8"));
|
|
2680
|
+
return new Map(Object.entries({ ...pkg.devDependencies, ...pkg.dependencies }));
|
|
2681
|
+
}
|
|
2682
|
+
function installedNames(specs, root, before, after) {
|
|
2683
|
+
const names = specs.map((spec) => packageNameFromSpec(spec, root)).filter((name) => !!name);
|
|
2684
|
+
for (const [name, version2] of after) {
|
|
2685
|
+
if (before.get(name) !== version2)
|
|
2686
|
+
names.push(name);
|
|
2687
|
+
}
|
|
2688
|
+
return [...new Set(names)];
|
|
2689
|
+
}
|
|
2690
|
+
function describePlugin(plugin, engineVersion) {
|
|
2691
|
+
const { manifest } = plugin;
|
|
2692
|
+
const lines = [`${plugin.package}${plugin.version ? `@${plugin.version}` : ""} is a MavonEngine plugin (${TARGET_LABELS[manifest.target]})`];
|
|
2693
|
+
const prefabs = Object.keys(manifest.assets?.prefabs ?? {});
|
|
2694
|
+
if (prefabs.length)
|
|
2695
|
+
lines.push(` Prefabs: ${prefabs.join(", ")}. Their assets ship in the release once you place them in the editor`);
|
|
2696
|
+
if (manifest.engine)
|
|
2697
|
+
lines.push(` Made for @mavonengine/core ${manifest.engine}${engineVersion ? `, you have ${engineVersion}` : ""}`);
|
|
2698
|
+
return `${lines.join("\n")}
|
|
2699
|
+
`;
|
|
2700
|
+
}
|
|
2701
|
+
function confirmOnTerminal(question) {
|
|
2702
|
+
const rl = createInterface({ input: process5.stdin, output: process5.stdout });
|
|
2703
|
+
return rl.question(question).then((answer) => ["", "y", "yes"].includes(answer.trim().toLowerCase())).finally(() => rl.close());
|
|
2704
|
+
}
|
|
2705
|
+
var terminalIO = {
|
|
2706
|
+
run: (command, args, cwd) => runCommand(command, args, cwd, {
|
|
2707
|
+
report: { step: () => {
|
|
2708
|
+
}, output: (chunk) => process5.stdout.write(chunk) }
|
|
2709
|
+
}),
|
|
2710
|
+
confirm: confirmOnTerminal,
|
|
2711
|
+
get interactive() {
|
|
2712
|
+
return !!process5.stdin.isTTY && !!process5.stdout.isTTY;
|
|
2713
|
+
},
|
|
2714
|
+
write: (text) => process5.stdout.write(text)
|
|
2715
|
+
};
|
|
2716
|
+
async function runInstallCommand(options, io = terminalIO) {
|
|
2717
|
+
const root = findProjectRoot(options.cwd);
|
|
2718
|
+
if (!root)
|
|
2719
|
+
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
2720
|
+
const config = readConfig(root);
|
|
2721
|
+
const pm = detectPackageManager(root);
|
|
2722
|
+
const args = installArgs(pm, options.packages);
|
|
2723
|
+
const before = dependencyNames(root);
|
|
2724
|
+
io.write(`mavonengine: running '${pm} ${args.join(" ")}' in ${root}
|
|
2725
|
+
`);
|
|
2726
|
+
await io.run(pm, args, root);
|
|
2727
|
+
const engineVersion = installedEngineVersion(root);
|
|
2728
|
+
let failed = false;
|
|
2729
|
+
const names = installedNames(options.packages, root, before, dependencyNames(root));
|
|
2730
|
+
if (names.length === 0)
|
|
2731
|
+
io.write(`
|
|
2732
|
+
mavonengine: couldn't tell which packages were installed. If one is a plugin, run 'mavonengine install <its name>' to register it
|
|
2733
|
+
`);
|
|
2734
|
+
for (const name of names) {
|
|
2735
|
+
let plugin;
|
|
2736
|
+
try {
|
|
2737
|
+
plugin = readPluginManifest(root, name);
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
io.write(`
|
|
2740
|
+
mavonengine: ${err.message}
|
|
2741
|
+
`);
|
|
2742
|
+
failed = true;
|
|
2743
|
+
continue;
|
|
2744
|
+
}
|
|
2745
|
+
if (!plugin) {
|
|
2746
|
+
io.write(`
|
|
2747
|
+
${name} is installed. It is not a MavonEngine plugin (no "mavonengine" field in its package.json), so ${CONFIG_FILE} is left alone
|
|
2748
|
+
`);
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
io.write(`
|
|
2752
|
+
${describePlugin(plugin, engineVersion)}`);
|
|
2753
|
+
const entry = config.plugins[name];
|
|
2754
|
+
if (entry && entry.enabled !== false) {
|
|
2755
|
+
io.write(` Already in ${CONFIG_FILE}
|
|
2756
|
+
`);
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2759
|
+
if (!options.register)
|
|
2760
|
+
continue;
|
|
2761
|
+
if (!options.yes && !io.interactive) {
|
|
2762
|
+
io.write(` Not added to ${CONFIG_FILE}: run with --yes, or add "${name}": { "enabled": true } to its "plugins"
|
|
2763
|
+
`);
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
if (!options.yes && !await io.confirm(`Add ${name} to the plugins in ${CONFIG_FILE}? (Y/n) `)) {
|
|
2767
|
+
io.write(` Not added. Add "${name}": { "enabled": true } to "plugins" in ${CONFIG_FILE} to use it later
|
|
2768
|
+
`);
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2771
|
+
addPluginToConfig(root, name);
|
|
2772
|
+
io.write(` Added to ${join3(root, CONFIG_FILE)}
|
|
2773
|
+
`);
|
|
2774
|
+
if (plugin.manifest.target !== "client")
|
|
2775
|
+
io.write(` Restart the game server ('mavonengine dev') to load it there
|
|
2776
|
+
`);
|
|
2777
|
+
}
|
|
2778
|
+
return failed ? 1 : 0;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// src/dev/installer.ts
|
|
2782
|
+
var MAX_INSTALL_OUTPUT = 2e4;
|
|
2783
|
+
var InstallRefused = class extends Error {
|
|
2784
|
+
constructor(message, reason) {
|
|
2785
|
+
super(message);
|
|
2786
|
+
this.reason = reason;
|
|
1329
2787
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
2788
|
+
reason;
|
|
2789
|
+
};
|
|
2790
|
+
function installWithCli(root, packages, write) {
|
|
2791
|
+
return runInstallCommand({ cwd: root, packages, yes: true, register: true }, {
|
|
2792
|
+
run: (command, args, cwd) => runCommand(command, args, cwd, { report: { step: () => {
|
|
2793
|
+
}, output: write } }),
|
|
2794
|
+
// Whoever asked for the install already agreed to it
|
|
2795
|
+
confirm: async () => true,
|
|
2796
|
+
interactive: false,
|
|
2797
|
+
write
|
|
2798
|
+
});
|
|
1332
2799
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
2800
|
+
var PluginInstaller = class {
|
|
2801
|
+
constructor(options) {
|
|
2802
|
+
this.options = options;
|
|
2803
|
+
}
|
|
2804
|
+
options;
|
|
2805
|
+
/** The running or last install */
|
|
2806
|
+
job = null;
|
|
2807
|
+
/** Settles when the current install and the restart after it are over */
|
|
2808
|
+
finished = Promise.resolve();
|
|
2809
|
+
nextId = 1;
|
|
2810
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2811
|
+
get busy() {
|
|
2812
|
+
return this.job?.status === "installing" || this.job?.status === "restarting";
|
|
2813
|
+
}
|
|
2814
|
+
onChange(listener) {
|
|
2815
|
+
this.listeners.add(listener);
|
|
2816
|
+
return () => this.listeners.delete(listener);
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Start installing `packages`. Throws InstallRefused when they can't be installed or an install is running.
|
|
2820
|
+
*/
|
|
2821
|
+
start(packages) {
|
|
2822
|
+
const invalid = invalidPackages(packages);
|
|
2823
|
+
if (invalid)
|
|
2824
|
+
throw new InstallRefused(invalid, "invalid");
|
|
2825
|
+
if (this.busy)
|
|
2826
|
+
throw new InstallRefused(`Still installing ${this.job.packages.join(", ")}`, "busy");
|
|
2827
|
+
const job = { id: this.nextId++, packages: [...packages], status: "installing", output: "" };
|
|
2828
|
+
this.job = job;
|
|
2829
|
+
this.finished = this.run(job);
|
|
2830
|
+
return job;
|
|
2831
|
+
}
|
|
2832
|
+
async run(job) {
|
|
2833
|
+
const changed = () => this.listeners.forEach((listener) => listener(job));
|
|
2834
|
+
const write = (text) => {
|
|
2835
|
+
job.output = (job.output + text).slice(-MAX_INSTALL_OUTPUT);
|
|
2836
|
+
changed();
|
|
2837
|
+
};
|
|
2838
|
+
changed();
|
|
2839
|
+
try {
|
|
2840
|
+
const code = await (this.options.install ?? installWithCli)(this.options.root, job.packages, write);
|
|
2841
|
+
if (code !== 0)
|
|
2842
|
+
throw new Error("The packages were installed, but not all of them could be set up as plugins");
|
|
2843
|
+
job.status = "restarting";
|
|
2844
|
+
write("\nmavonengine: restarting the dev script to load what was installed\n");
|
|
2845
|
+
await this.options.restart();
|
|
2846
|
+
job.status = "done";
|
|
2847
|
+
} catch (err) {
|
|
2848
|
+
job.status = "failed";
|
|
2849
|
+
job.error = err.message;
|
|
2850
|
+
}
|
|
2851
|
+
changed();
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
|
|
2855
|
+
// src/dev/control.ts
|
|
2856
|
+
var MAX_BODY_BYTES = 8192;
|
|
2857
|
+
function readBody(req) {
|
|
2858
|
+
return new Promise((resolve7, reject) => {
|
|
2859
|
+
let body = "";
|
|
2860
|
+
req.setEncoding("utf8");
|
|
2861
|
+
req.on("data", (chunk) => {
|
|
2862
|
+
body += chunk;
|
|
2863
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
2864
|
+
reject(new Error("Body too large"));
|
|
2865
|
+
req.destroy();
|
|
2866
|
+
}
|
|
2867
|
+
});
|
|
2868
|
+
req.on("end", () => resolve7(body));
|
|
2869
|
+
req.on("error", reject);
|
|
1338
2870
|
});
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
2871
|
+
}
|
|
2872
|
+
function sameToken(given, token) {
|
|
2873
|
+
if (typeof given !== "string")
|
|
2874
|
+
return false;
|
|
2875
|
+
const a = Buffer.from(given);
|
|
2876
|
+
const b = Buffer.from(token);
|
|
2877
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
2878
|
+
}
|
|
2879
|
+
function startDevControl(installer) {
|
|
2880
|
+
const token = randomBytes(24).toString("hex");
|
|
2881
|
+
const handle = async (req, res) => {
|
|
2882
|
+
const reply = (status, body) => {
|
|
2883
|
+
res.statusCode = status;
|
|
2884
|
+
res.setHeader("Content-Type", "application/json");
|
|
2885
|
+
res.end(JSON.stringify(body));
|
|
2886
|
+
};
|
|
2887
|
+
if (!sameToken(req.headers[DEV_CONTROL_TOKEN_HEADER], token)) {
|
|
2888
|
+
reply(401, { error: "Wrong or missing token" });
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
2892
|
+
reply(200, { job: installer.job });
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (req.method === "POST" && req.url === "/install") {
|
|
2896
|
+
let packages;
|
|
2897
|
+
try {
|
|
2898
|
+
({ packages } = JSON.parse(await readBody(req)));
|
|
2899
|
+
} catch {
|
|
2900
|
+
reply(400, { error: "Expected a JSON body" });
|
|
1351
2901
|
return;
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
openBrowser(options.clientUrl);
|
|
1362
|
-
const tab = tabForKey(event.key);
|
|
1363
|
-
if (tab !== null) {
|
|
1364
|
-
view.tab = tab;
|
|
1365
|
-
app.invalidate();
|
|
2902
|
+
}
|
|
2903
|
+
try {
|
|
2904
|
+
reply(202, { job: installer.start(packages) });
|
|
2905
|
+
} catch (err) {
|
|
2906
|
+
if (!(err instanceof InstallRefused))
|
|
2907
|
+
throw err;
|
|
2908
|
+
reply(err.reason === "busy" ? 409 : 400, { error: err.message });
|
|
2909
|
+
}
|
|
2910
|
+
return;
|
|
1366
2911
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
2912
|
+
reply(404, { error: "Not found" });
|
|
2913
|
+
};
|
|
2914
|
+
const server = createServer((req, res) => {
|
|
2915
|
+
handle(req, res).catch((err) => {
|
|
2916
|
+
res.statusCode = 500;
|
|
2917
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2918
|
+
});
|
|
2919
|
+
});
|
|
2920
|
+
return new Promise((resolve7, reject) => {
|
|
2921
|
+
server.once("error", reject);
|
|
2922
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2923
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
2924
|
+
resolve7({
|
|
2925
|
+
url,
|
|
2926
|
+
token,
|
|
2927
|
+
env: { [DEV_CONTROL_URL_ENV]: url, [DEV_CONTROL_TOKEN_ENV]: token },
|
|
2928
|
+
close: () => new Promise((done) => {
|
|
2929
|
+
server.close(() => done());
|
|
2930
|
+
server.closeAllConnections();
|
|
2931
|
+
})
|
|
2932
|
+
});
|
|
2933
|
+
});
|
|
1369
2934
|
});
|
|
1370
|
-
app.on("exit", () => client.stop());
|
|
1371
|
-
const onAbort = () => app.stop();
|
|
1372
|
-
signal?.addEventListener("abort", onAbort);
|
|
1373
|
-
client.start();
|
|
1374
|
-
if (!signal?.aborted)
|
|
1375
|
-
await app.start();
|
|
1376
|
-
signal?.removeEventListener("abort", onAbort);
|
|
1377
|
-
client.stop();
|
|
1378
2935
|
}
|
|
1379
2936
|
|
|
1380
2937
|
// src/commands/dev.ts
|
|
1381
|
-
import { execFileSync, spawn as spawn3 } from "child_process";
|
|
1382
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1383
|
-
import { constants } from "os";
|
|
1384
|
-
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
1385
|
-
import process4 from "process";
|
|
1386
|
-
import { setTimeout as delay } from "timers/promises";
|
|
1387
|
-
import { parseArgs as parseArgs2 } from "util";
|
|
1388
2938
|
var DEV_HELP = `Usage: mavonengine dev [options]
|
|
1389
2939
|
|
|
1390
|
-
Run the dev
|
|
2940
|
+
Run the dev servers of the MavonEngine project you are in (game server and client)
|
|
1391
2941
|
and show the dashboard for it. Once the game server is up, the game opens in the
|
|
1392
2942
|
browser. Quitting the dashboard stops the dev servers.
|
|
1393
2943
|
|
|
2944
|
+
Runs the project's 'dev:server' script, or its 'dev' script when there is none.
|
|
2945
|
+
So 'dev' can be 'mavonengine dev' itself, as in the multiplayer template.
|
|
2946
|
+
|
|
1394
2947
|
Options:
|
|
1395
2948
|
--cwd <dir> Start looking for the project here (default: current directory)
|
|
1396
2949
|
--no-dashboard Only run the dev script, with its output in the terminal
|
|
@@ -1411,6 +2964,9 @@ var LOCKFILES = [
|
|
|
1411
2964
|
["package-lock.json", "npm"]
|
|
1412
2965
|
];
|
|
1413
2966
|
var WHITESPACE_RE = /\s+/;
|
|
2967
|
+
var RUNS_MAVONENGINE_DEV_RE = /\bmavonengine\s+dev\b/;
|
|
2968
|
+
var DEV_ENV_FLAG = "MAVONENGINE_DEV_SCRIPT";
|
|
2969
|
+
var DEV_SERVER_SCRIPT = "dev:server";
|
|
1414
2970
|
var DEV_FLAGS = {
|
|
1415
2971
|
cwd: { type: "string" },
|
|
1416
2972
|
dashboard: { type: "boolean", default: true },
|
|
@@ -1420,8 +2976,8 @@ var DEV_FLAGS = {
|
|
|
1420
2976
|
open: { type: "boolean", default: true },
|
|
1421
2977
|
help: { type: "boolean", short: "h", default: false }
|
|
1422
2978
|
};
|
|
1423
|
-
function parseDevArgs(args, cwd =
|
|
1424
|
-
const { values } =
|
|
2979
|
+
function parseDevArgs(args, cwd = process6.cwd(), env = process6.env) {
|
|
2980
|
+
const { values } = parseArgs3({
|
|
1425
2981
|
args,
|
|
1426
2982
|
options: DEV_FLAGS,
|
|
1427
2983
|
strict: true,
|
|
@@ -1443,33 +2999,50 @@ function toDevOptions(values, cwd, env) {
|
|
|
1443
2999
|
dashboard = parseDashboardArgs(dashboardArgs, env);
|
|
1444
3000
|
dashboard.openBrowser = values.open && canOpenBrowserHere(env);
|
|
1445
3001
|
}
|
|
1446
|
-
return { cwd:
|
|
3002
|
+
return { cwd: resolve4(cwd, values.cwd ?? "."), dashboard };
|
|
1447
3003
|
}
|
|
1448
3004
|
function readPackageJson(dir) {
|
|
1449
|
-
const path =
|
|
1450
|
-
if (!
|
|
3005
|
+
const path = join4(dir, "package.json");
|
|
3006
|
+
if (!existsSync4(path))
|
|
1451
3007
|
return void 0;
|
|
1452
3008
|
try {
|
|
1453
|
-
return JSON.parse(
|
|
3009
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
1454
3010
|
} catch {
|
|
1455
3011
|
return void 0;
|
|
1456
3012
|
}
|
|
1457
3013
|
}
|
|
3014
|
+
function devScriptOf(pkg) {
|
|
3015
|
+
const scripts = pkg?.scripts ?? {};
|
|
3016
|
+
if (typeof scripts[DEV_SERVER_SCRIPT] === "string")
|
|
3017
|
+
return DEV_SERVER_SCRIPT;
|
|
3018
|
+
return typeof scripts.dev === "string" ? "dev" : void 0;
|
|
3019
|
+
}
|
|
3020
|
+
function checkDevScript(root, env = process6.env) {
|
|
3021
|
+
const pkg = readPackageJson(root);
|
|
3022
|
+
const script = devScriptOf(pkg);
|
|
3023
|
+
if (!script)
|
|
3024
|
+
throw new UsageError(`${join4(root, "package.json")} has no '${DEV_SERVER_SCRIPT}' or 'dev' script`);
|
|
3025
|
+
const command = String(pkg?.scripts?.[script] ?? "");
|
|
3026
|
+
if (env[DEV_ENV_FLAG] || RUNS_MAVONENGINE_DEV_RE.test(command)) {
|
|
3027
|
+
throw new UsageError(`the '${script}' script starts 'mavonengine dev' again. Put the command that starts the game server in a '${DEV_SERVER_SCRIPT}' script: 'mavonengine dev' runs that instead of 'dev'`);
|
|
3028
|
+
}
|
|
3029
|
+
return script;
|
|
3030
|
+
}
|
|
1458
3031
|
function findProjectRoot(start) {
|
|
1459
|
-
for (let dir =
|
|
3032
|
+
for (let dir = resolve4(start); ; dir = dirname3(dir)) {
|
|
1460
3033
|
const pkg = readPackageJson(dir);
|
|
1461
3034
|
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
1462
|
-
if ("@mavonengine/core" in deps &&
|
|
3035
|
+
if ("@mavonengine/core" in deps && devScriptOf(pkg))
|
|
1463
3036
|
return dir;
|
|
1464
|
-
if (
|
|
3037
|
+
if (dirname3(dir) === dir)
|
|
1465
3038
|
return void 0;
|
|
1466
3039
|
}
|
|
1467
3040
|
}
|
|
1468
3041
|
function installedEngineVersion(root) {
|
|
1469
|
-
return readPackageJson(
|
|
3042
|
+
return readPackageJson(join4(root, "node_modules", "@mavonengine", "core"))?.version;
|
|
1470
3043
|
}
|
|
1471
3044
|
function detectPackageManager(root) {
|
|
1472
|
-
return LOCKFILES.find(([file]) =>
|
|
3045
|
+
return LOCKFILES.find(([file]) => existsSync4(join4(root, file)))?.[1] ?? "npm";
|
|
1473
3046
|
}
|
|
1474
3047
|
function descendants(root, psOutput) {
|
|
1475
3048
|
const children = /* @__PURE__ */ new Map();
|
|
@@ -1490,7 +3063,7 @@ function descendants(root, psOutput) {
|
|
|
1490
3063
|
return found;
|
|
1491
3064
|
}
|
|
1492
3065
|
function processTree(pid) {
|
|
1493
|
-
if (
|
|
3066
|
+
if (process6.platform === "win32")
|
|
1494
3067
|
return [];
|
|
1495
3068
|
try {
|
|
1496
3069
|
return [pid, ...descendants(pid, execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }))];
|
|
@@ -1501,14 +3074,14 @@ function processTree(pid) {
|
|
|
1501
3074
|
function signalAll(pids, signal) {
|
|
1502
3075
|
for (const p of pids) {
|
|
1503
3076
|
try {
|
|
1504
|
-
|
|
3077
|
+
process6.kill(p, signal);
|
|
1505
3078
|
} catch {
|
|
1506
3079
|
}
|
|
1507
3080
|
}
|
|
1508
3081
|
}
|
|
1509
3082
|
function isAlive(pid) {
|
|
1510
3083
|
try {
|
|
1511
|
-
|
|
3084
|
+
process6.kill(pid, 0);
|
|
1512
3085
|
return true;
|
|
1513
3086
|
} catch {
|
|
1514
3087
|
return false;
|
|
@@ -1520,7 +3093,7 @@ async function waitForExit(pids, timeoutMs) {
|
|
|
1520
3093
|
await delay(50);
|
|
1521
3094
|
}
|
|
1522
3095
|
function killTree(pid, signal) {
|
|
1523
|
-
if (
|
|
3096
|
+
if (process6.platform === "win32") {
|
|
1524
3097
|
try {
|
|
1525
3098
|
execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
1526
3099
|
} catch {
|
|
@@ -1529,24 +3102,26 @@ function killTree(pid, signal) {
|
|
|
1529
3102
|
}
|
|
1530
3103
|
signalAll(processTree(pid), signal);
|
|
1531
3104
|
}
|
|
1532
|
-
function startDev(root, { capture = false } = {}) {
|
|
3105
|
+
function startDev(root, { capture = false, env = {} } = {}) {
|
|
1533
3106
|
const pm = detectPackageManager(root);
|
|
1534
|
-
const
|
|
3107
|
+
const script = checkDevScript(root);
|
|
3108
|
+
const child = spawn3(pm, ["run", script], {
|
|
1535
3109
|
cwd: root,
|
|
3110
|
+
env: { ...process6.env, ...env, [DEV_ENV_FLAG]: "1" },
|
|
1536
3111
|
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
1537
3112
|
// npm, pnpm and yarn are .cmd shims on Windows
|
|
1538
|
-
shell:
|
|
3113
|
+
shell: process6.platform === "win32"
|
|
1539
3114
|
});
|
|
1540
3115
|
let output = "";
|
|
1541
3116
|
const keep = (chunk) => {
|
|
1542
3117
|
output = (output + chunk).slice(-MAX_CAPTURED_OUTPUT);
|
|
1543
3118
|
};
|
|
1544
|
-
const announce = `mavonengine: running '${pm} run
|
|
3119
|
+
const announce = `mavonengine: running '${pm} run ${script}' in ${root}
|
|
1545
3120
|
`;
|
|
1546
3121
|
if (capture)
|
|
1547
3122
|
keep(announce);
|
|
1548
3123
|
else
|
|
1549
|
-
|
|
3124
|
+
process6.stdout.write(announce);
|
|
1550
3125
|
child.stdout?.setEncoding("utf8").on("data", keep);
|
|
1551
3126
|
child.stderr?.setEncoding("utf8").on("data", keep);
|
|
1552
3127
|
let running = true;
|
|
@@ -1554,17 +3129,17 @@ function startDev(root, { capture = false } = {}) {
|
|
|
1554
3129
|
};
|
|
1555
3130
|
const forward = (signal) => child.pid && killTree(child.pid, signal);
|
|
1556
3131
|
const onExit = () => running && child.pid && killTree(child.pid, "SIGTERM");
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
3132
|
+
process6.on("SIGINT", onInterrupt);
|
|
3133
|
+
process6.on("SIGTERM", forward);
|
|
3134
|
+
process6.on("SIGHUP", forward);
|
|
3135
|
+
process6.on("exit", onExit);
|
|
1561
3136
|
const exited = new Promise((resolvePromise, reject) => {
|
|
1562
3137
|
const cleanup = () => {
|
|
1563
3138
|
running = false;
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
3139
|
+
process6.off("SIGINT", onInterrupt);
|
|
3140
|
+
process6.off("SIGTERM", forward);
|
|
3141
|
+
process6.off("SIGHUP", forward);
|
|
3142
|
+
process6.off("exit", onExit);
|
|
1568
3143
|
};
|
|
1569
3144
|
child.on("error", (err) => {
|
|
1570
3145
|
cleanup();
|
|
@@ -1577,7 +3152,7 @@ function startDev(root, { capture = false } = {}) {
|
|
|
1577
3152
|
});
|
|
1578
3153
|
const stop = async () => {
|
|
1579
3154
|
if (running && child.pid) {
|
|
1580
|
-
if (
|
|
3155
|
+
if (process6.platform === "win32") {
|
|
1581
3156
|
killTree(child.pid, "SIGTERM");
|
|
1582
3157
|
} else {
|
|
1583
3158
|
const tree = processTree(child.pid);
|
|
@@ -1591,28 +3166,83 @@ function startDev(root, { capture = false } = {}) {
|
|
|
1591
3166
|
};
|
|
1592
3167
|
return { exited, stop, output: () => output };
|
|
1593
3168
|
}
|
|
1594
|
-
function
|
|
1595
|
-
|
|
3169
|
+
function startDevSession(root, options = {}) {
|
|
3170
|
+
let dev = startDev(root, options);
|
|
3171
|
+
let restarting = false;
|
|
3172
|
+
let settle;
|
|
3173
|
+
const exited = new Promise((resolve7, reject) => settle = { resolve: resolve7, reject });
|
|
3174
|
+
const watch = (current) => {
|
|
3175
|
+
current.exited.then((code) => {
|
|
3176
|
+
if (current === dev && !restarting)
|
|
3177
|
+
settle.resolve(code);
|
|
3178
|
+
}, (err) => settle.reject(err));
|
|
3179
|
+
};
|
|
3180
|
+
watch(dev);
|
|
3181
|
+
return {
|
|
3182
|
+
exited,
|
|
3183
|
+
output: () => dev.output(),
|
|
3184
|
+
stop: () => dev.stop(),
|
|
3185
|
+
async restart() {
|
|
3186
|
+
restarting = true;
|
|
3187
|
+
try {
|
|
3188
|
+
await dev.stop().catch(() => {
|
|
3189
|
+
});
|
|
3190
|
+
dev = startDev(root, options);
|
|
3191
|
+
} finally {
|
|
3192
|
+
restarting = false;
|
|
3193
|
+
}
|
|
3194
|
+
watch(dev);
|
|
3195
|
+
}
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
async function startDevWithInstaller(root, { capture = false, onOutput } = {}) {
|
|
3199
|
+
let session;
|
|
3200
|
+
const installer = new PluginInstaller({ root, restart: async () => session?.restart() });
|
|
3201
|
+
const control = await startDevControl(installer);
|
|
3202
|
+
let written = 0;
|
|
3203
|
+
installer.onChange((job) => {
|
|
3204
|
+
onOutput?.(job.output.slice(written <= job.output.length ? written : 0));
|
|
3205
|
+
written = job.output.length;
|
|
3206
|
+
if (job.status === "done" || job.status === "failed")
|
|
3207
|
+
written = 0;
|
|
3208
|
+
});
|
|
3209
|
+
try {
|
|
3210
|
+
session = startDevSession(root, { capture, env: control.env });
|
|
3211
|
+
} catch (err) {
|
|
3212
|
+
await control.close();
|
|
3213
|
+
throw err;
|
|
3214
|
+
}
|
|
3215
|
+
return { session, installer, close: () => control.close() };
|
|
3216
|
+
}
|
|
3217
|
+
async function runDev(root) {
|
|
3218
|
+
const { session, close } = await startDevWithInstaller(root, { onOutput: (text) => process6.stdout.write(text) });
|
|
3219
|
+
try {
|
|
3220
|
+
return await session.exited;
|
|
3221
|
+
} finally {
|
|
3222
|
+
await close();
|
|
3223
|
+
}
|
|
1596
3224
|
}
|
|
1597
3225
|
async function runDevWithDashboard(root, dashboard) {
|
|
1598
|
-
const dev =
|
|
3226
|
+
const { session: dev, installer, close } = await startDevWithInstaller(root, { capture: true });
|
|
1599
3227
|
const engineVersion = installedEngineVersion(root);
|
|
1600
3228
|
const startup = () => ({ engineVersion, output: dev.output() });
|
|
1601
3229
|
const devExited = new AbortController();
|
|
1602
3230
|
dev.exited.then(() => devExited.abort(), () => devExited.abort());
|
|
1603
3231
|
let quit = false;
|
|
1604
3232
|
try {
|
|
1605
|
-
await runDashboard({ ...dashboard, startup }, devExited.signal);
|
|
3233
|
+
await runDashboard({ ...dashboard, startup, installer }, devExited.signal);
|
|
1606
3234
|
} finally {
|
|
1607
3235
|
quit = !devExited.signal.aborted;
|
|
3236
|
+
await installer.finished;
|
|
1608
3237
|
if (quit)
|
|
1609
3238
|
await dev.stop().catch(() => {
|
|
1610
3239
|
});
|
|
3240
|
+
await close();
|
|
1611
3241
|
}
|
|
1612
3242
|
if (quit)
|
|
1613
3243
|
return 0;
|
|
1614
3244
|
const exitCode = await dev.exited;
|
|
1615
|
-
|
|
3245
|
+
process6.stderr.write(`${dev.output()}
|
|
1616
3246
|
mavonengine: the dev script exited with code ${exitCode}
|
|
1617
3247
|
`);
|
|
1618
3248
|
return exitCode;
|
|
@@ -1621,12 +3251,91 @@ async function runDevCommand(options) {
|
|
|
1621
3251
|
const root = findProjectRoot(options.cwd);
|
|
1622
3252
|
if (!root)
|
|
1623
3253
|
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
1624
|
-
|
|
3254
|
+
checkDevScript(root);
|
|
3255
|
+
if (options.dashboard && process6.stdout.isTTY && process6.stdin.isTTY)
|
|
1625
3256
|
return runDevWithDashboard(root, options.dashboard);
|
|
1626
3257
|
return runDev(root);
|
|
1627
3258
|
}
|
|
1628
3259
|
|
|
3260
|
+
// src/commands/build.ts
|
|
3261
|
+
var BUILD_HELP = `Usage: mavonengine build [options]
|
|
3262
|
+
|
|
3263
|
+
Build the MavonEngine project you are in for release by running its build script.
|
|
3264
|
+
Plugin assets go into the client's output under plugins/<package>/: the shared
|
|
3265
|
+
ones, and those of the prefabs listed in ${CONFIG_FILE}. The others are left out.
|
|
3266
|
+
|
|
3267
|
+
Options:
|
|
3268
|
+
--cwd <dir> Start looking for the project here (default: current directory)
|
|
3269
|
+
-h, --help Show this help
|
|
3270
|
+
`;
|
|
3271
|
+
function parseBuildArgs(args, cwd = process7.cwd()) {
|
|
3272
|
+
const { values } = parseArgs4({
|
|
3273
|
+
args,
|
|
3274
|
+
options: {
|
|
3275
|
+
cwd: { type: "string" },
|
|
3276
|
+
help: { type: "boolean", short: "h", default: false }
|
|
3277
|
+
},
|
|
3278
|
+
strict: true,
|
|
3279
|
+
allowPositionals: false
|
|
3280
|
+
});
|
|
3281
|
+
if (values.help)
|
|
3282
|
+
return "help";
|
|
3283
|
+
return { cwd: resolve5(cwd, values.cwd ?? ".") };
|
|
3284
|
+
}
|
|
3285
|
+
function summarizeReleaseAssets(root, assets, warnings) {
|
|
3286
|
+
const plugins = readConfig(root).plugins;
|
|
3287
|
+
const enabled = Object.entries(plugins).filter(([, entry]) => entry?.enabled !== false);
|
|
3288
|
+
if (enabled.length === 0)
|
|
3289
|
+
return "";
|
|
3290
|
+
const lines = [`
|
|
3291
|
+
Plugins in ${CONFIG_FILE}:`];
|
|
3292
|
+
for (const [pkg, entry] of enabled) {
|
|
3293
|
+
const count = assets.filter((a) => a.package === pkg).length;
|
|
3294
|
+
const prefabs = entry?.prefabs?.length ? `, prefabs: ${entry.prefabs.join(", ")}` : "";
|
|
3295
|
+
lines.push(` ${pkg}: ${count} asset${count === 1 ? "" : "s"}${prefabs}`);
|
|
3296
|
+
}
|
|
3297
|
+
for (const warning of warnings)
|
|
3298
|
+
lines.push(` warning: ${warning}`);
|
|
3299
|
+
return `${lines.join("\n")}
|
|
3300
|
+
`;
|
|
3301
|
+
}
|
|
3302
|
+
function run(command, args, cwd) {
|
|
3303
|
+
return new Promise((resolvePromise, reject) => {
|
|
3304
|
+
const child = spawn4(command, args, { cwd, stdio: "inherit", shell: process7.platform === "win32" });
|
|
3305
|
+
child.on("error", (err) => {
|
|
3306
|
+
reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
|
|
3307
|
+
});
|
|
3308
|
+
child.on("exit", (code) => resolvePromise(code ?? 1));
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
async function runBuildCommand(options) {
|
|
3312
|
+
const root = findProjectRoot(options.cwd);
|
|
3313
|
+
if (!root)
|
|
3314
|
+
throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`);
|
|
3315
|
+
const pkg = JSON.parse(readFileSync5(join5(root, "package.json"), "utf8"));
|
|
3316
|
+
if (typeof pkg.scripts?.build !== "string")
|
|
3317
|
+
throw new UsageError(`${join5(root, "package.json")} has no build script`);
|
|
3318
|
+
const pm = detectPackageManager(root);
|
|
3319
|
+
process7.stdout.write(`mavonengine: running '${pm} run build' in ${root}
|
|
3320
|
+
`);
|
|
3321
|
+
const code = await run(pm, ["run", "build"], root);
|
|
3322
|
+
if (code !== 0) {
|
|
3323
|
+
process7.stderr.write(`mavonengine: the build script exited with code ${code}
|
|
3324
|
+
`);
|
|
3325
|
+
return code;
|
|
3326
|
+
}
|
|
3327
|
+
if (existsSync5(join5(root, CONFIG_FILE))) {
|
|
3328
|
+
const { assets, warnings } = releaseAssets(root);
|
|
3329
|
+
process7.stdout.write(summarizeReleaseAssets(root, assets, warnings));
|
|
3330
|
+
}
|
|
3331
|
+
return 0;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
1629
3334
|
// src/commands/create.ts
|
|
3335
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3336
|
+
import process8 from "process";
|
|
3337
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
3338
|
+
import { parseArgs as parseArgs5 } from "util";
|
|
1630
3339
|
var CREATE_HELP = `Usage: mavonengine create [name] [options]
|
|
1631
3340
|
|
|
1632
3341
|
Create a MavonEngine project from the multiplayer template, install its
|
|
@@ -1642,8 +3351,8 @@ Options:
|
|
|
1642
3351
|
-h, --help Show this help
|
|
1643
3352
|
`;
|
|
1644
3353
|
var MAX_SETUP_OUTPUT = 64 * 1024;
|
|
1645
|
-
function parseCreateArgs(args, cwd =
|
|
1646
|
-
const { values, positionals } =
|
|
3354
|
+
function parseCreateArgs(args, cwd = process8.cwd(), env = process8.env) {
|
|
3355
|
+
const { values, positionals } = parseArgs5({
|
|
1647
3356
|
args,
|
|
1648
3357
|
options: DEV_FLAGS,
|
|
1649
3358
|
strict: true,
|
|
@@ -1664,7 +3373,7 @@ function checkProjectName(name) {
|
|
|
1664
3373
|
throw new UsageError(`invalid project name '${name}'`);
|
|
1665
3374
|
}
|
|
1666
3375
|
async function promptProjectName() {
|
|
1667
|
-
const rl =
|
|
3376
|
+
const rl = createInterface2({ input: process8.stdin, output: process8.stdout });
|
|
1668
3377
|
try {
|
|
1669
3378
|
return (await rl.question("Project name: ")).trim();
|
|
1670
3379
|
} finally {
|
|
@@ -1718,18 +3427,18 @@ async function runCreateWithDashboard(projectName, parentDir, dashboard) {
|
|
|
1718
3427
|
return 0;
|
|
1719
3428
|
await work.catch(() => {
|
|
1720
3429
|
});
|
|
1721
|
-
|
|
3430
|
+
process8.stderr.write(`mavonengine: stopped creating the project, what was set up so far is in ${projectDirFor(projectName, parentDir)}
|
|
1722
3431
|
`);
|
|
1723
3432
|
return 130;
|
|
1724
3433
|
}
|
|
1725
3434
|
try {
|
|
1726
3435
|
const exitCode = await work;
|
|
1727
|
-
|
|
3436
|
+
process8.stderr.write(`${dev.output()}
|
|
1728
3437
|
mavonengine: the dev script exited with code ${exitCode}
|
|
1729
3438
|
`);
|
|
1730
3439
|
return exitCode;
|
|
1731
3440
|
} catch (err) {
|
|
1732
|
-
|
|
3441
|
+
process8.stderr.write(output);
|
|
1733
3442
|
throw err;
|
|
1734
3443
|
}
|
|
1735
3444
|
}
|
|
@@ -1737,16 +3446,16 @@ async function runCreatePlain(projectName, parentDir) {
|
|
|
1737
3446
|
const report = {
|
|
1738
3447
|
step: () => {
|
|
1739
3448
|
},
|
|
1740
|
-
output: (chunk) =>
|
|
3449
|
+
output: (chunk) => process8.stdout.write(chunk)
|
|
1741
3450
|
};
|
|
1742
3451
|
const dir = await createProject({ projectName, parentDir, report });
|
|
1743
|
-
|
|
3452
|
+
process8.stdout.write(`
|
|
1744
3453
|
mavonengine: your project is ready at ${dir}
|
|
1745
3454
|
`);
|
|
1746
3455
|
return runDev(dir);
|
|
1747
3456
|
}
|
|
1748
3457
|
async function runCreateCommand(options) {
|
|
1749
|
-
const interactive = !!
|
|
3458
|
+
const interactive = !!process8.stdout.isTTY && !!process8.stdin.isTTY;
|
|
1750
3459
|
let projectName = options.projectName;
|
|
1751
3460
|
if (!projectName) {
|
|
1752
3461
|
if (!interactive)
|
|
@@ -1757,13 +3466,349 @@ async function runCreateCommand(options) {
|
|
|
1757
3466
|
checkProjectName(projectName);
|
|
1758
3467
|
}
|
|
1759
3468
|
const dir = projectDirFor(projectName, options.cwd);
|
|
1760
|
-
if (
|
|
3469
|
+
if (existsSync6(dir))
|
|
1761
3470
|
throw new UsageError(`${dir} already exists`);
|
|
1762
3471
|
if (options.dashboard && interactive)
|
|
1763
3472
|
return runCreateWithDashboard(projectName, options.cwd, options.dashboard);
|
|
1764
3473
|
return runCreatePlain(projectName, options.cwd);
|
|
1765
3474
|
}
|
|
1766
3475
|
|
|
3476
|
+
// src/commands/mcp.ts
|
|
3477
|
+
import process9 from "process";
|
|
3478
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
3479
|
+
|
|
3480
|
+
// src/mcp/GameServerClient.ts
|
|
3481
|
+
var GameServerError = class extends Error {
|
|
3482
|
+
};
|
|
3483
|
+
var NET_UNAVAILABLE = "The network debug endpoints are not available: the game server runs with NODE_ENV=production (they are never served there), or it uses a @mavonengine/core release without them";
|
|
3484
|
+
function packetParams(query) {
|
|
3485
|
+
return Object.fromEntries(Object.entries(query).filter(([, value]) => value !== void 0 && value !== "").map(([key, value]) => [key, String(value)]));
|
|
3486
|
+
}
|
|
3487
|
+
var DEV_UNAVAILABLE = "The dev endpoints are not available: the game server runs with NODE_ENV=production (they are never served there), or it uses a @mavonengine/core release without them";
|
|
3488
|
+
var GameServerClient = class {
|
|
3489
|
+
url;
|
|
3490
|
+
clientUrl;
|
|
3491
|
+
base;
|
|
3492
|
+
clientBase;
|
|
3493
|
+
token;
|
|
3494
|
+
timeoutMs;
|
|
3495
|
+
fetchImpl;
|
|
3496
|
+
constructor({ url, clientUrl, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
|
|
3497
|
+
this.base = new URL(url.endsWith("/") ? url : `${url}/`);
|
|
3498
|
+
this.url = this.base.href;
|
|
3499
|
+
this.clientBase = clientUrl ? new URL(clientUrl.endsWith("/") ? clientUrl : `${clientUrl}/`) : this.base;
|
|
3500
|
+
this.clientUrl = this.clientBase.href;
|
|
3501
|
+
this.token = token;
|
|
3502
|
+
this.timeoutMs = timeoutMs;
|
|
3503
|
+
this.fetchImpl = fetch;
|
|
3504
|
+
}
|
|
3505
|
+
health() {
|
|
3506
|
+
return this.get("api/game/health");
|
|
3507
|
+
}
|
|
3508
|
+
metrics() {
|
|
3509
|
+
return this.get("api/game/metrics");
|
|
3510
|
+
}
|
|
3511
|
+
logs(afterId = 0) {
|
|
3512
|
+
return this.get("api/game/logs", { after: String(afterId) }, "The server does not keep logs (metrics.logBufferSize is 0)");
|
|
3513
|
+
}
|
|
3514
|
+
requests(afterId = 0) {
|
|
3515
|
+
return this.get("api/game/requests", { after: String(afterId) }, "The server does not record requests (metrics.requestLogSize is 0)");
|
|
3516
|
+
}
|
|
3517
|
+
scene() {
|
|
3518
|
+
return this.get("api/game/dev/scene", {}, DEV_UNAVAILABLE);
|
|
3519
|
+
}
|
|
3520
|
+
entities(query = {}) {
|
|
3521
|
+
const params = {};
|
|
3522
|
+
if (query.type)
|
|
3523
|
+
params.type = query.type;
|
|
3524
|
+
if (query.near)
|
|
3525
|
+
params.near = `${query.near.x},${query.near.y},${query.near.z}`;
|
|
3526
|
+
for (const key of ["radius", "limit", "offset"]) {
|
|
3527
|
+
if (query[key] !== void 0)
|
|
3528
|
+
params[key] = String(query[key]);
|
|
3529
|
+
}
|
|
3530
|
+
return this.get("api/game/dev/entities", params, DEV_UNAVAILABLE);
|
|
3531
|
+
}
|
|
3532
|
+
entity(id) {
|
|
3533
|
+
return this.get(`api/game/dev/entities/${encodeURIComponent(id)}`, {}, DEV_UNAVAILABLE);
|
|
3534
|
+
}
|
|
3535
|
+
/** The plugins of the project and whether each runs on the server */
|
|
3536
|
+
plugins() {
|
|
3537
|
+
return this.get("api/game/plugins", {}, "The game server doesn't list its plugins: it runs a @mavonengine/core release without GET /api/game/plugins, or its metrics endpoints are off");
|
|
3538
|
+
}
|
|
3539
|
+
/** Every HTTP endpoint of the server, with who registered it */
|
|
3540
|
+
routes() {
|
|
3541
|
+
return this.get("api/game/routes", {}, "The game server doesn't list its endpoints: it runs a @mavonengine/core release without GET /api/game/routes, or its metrics endpoints are off");
|
|
3542
|
+
}
|
|
3543
|
+
/** Packets and network events the server recorded, oldest first */
|
|
3544
|
+
packets(query = {}) {
|
|
3545
|
+
return this.get("api/game/dev/net/packets", packetParams(query), NET_UNAVAILABLE);
|
|
3546
|
+
}
|
|
3547
|
+
/** One recorded packet with its payload */
|
|
3548
|
+
packet(id) {
|
|
3549
|
+
return this.get(`api/game/dev/net/packets/${id}`, {}, NET_UNAVAILABLE);
|
|
3550
|
+
}
|
|
3551
|
+
/** Who is sent which entities */
|
|
3552
|
+
sync() {
|
|
3553
|
+
return this.get("api/game/dev/net/sync", {}, NET_UNAVAILABLE);
|
|
3554
|
+
}
|
|
3555
|
+
/** The game tabs open in a browser, through the dev bridge */
|
|
3556
|
+
clients() {
|
|
3557
|
+
return this.bridge("__mavonengine/clients", {});
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Ask one tab. `clientId` defaults to the only open tab (or the only one not running the editor)
|
|
3561
|
+
*/
|
|
3562
|
+
client(query, params = {}, clientId = "auto") {
|
|
3563
|
+
const defined = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)]));
|
|
3564
|
+
const timeoutMs = query === "trace" ? Number(params.durationMs ?? 3e3) + 15e3 : query === "screenshot" ? 15e3 : query === "status" ? 1e4 : 8e3;
|
|
3565
|
+
return this.bridge(`__mavonengine/clients/${encodeURIComponent(clientId)}/${query}`, defined, timeoutMs);
|
|
3566
|
+
}
|
|
3567
|
+
async bridge(path, params, timeoutMs = 5e3) {
|
|
3568
|
+
const target = new URL(path, this.clientBase);
|
|
3569
|
+
for (const [key, value] of Object.entries(params))
|
|
3570
|
+
target.searchParams.set(key, value);
|
|
3571
|
+
let res;
|
|
3572
|
+
try {
|
|
3573
|
+
res = await this.fetchImpl(target, {
|
|
3574
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
|
3575
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
3576
|
+
});
|
|
3577
|
+
} catch (err) {
|
|
3578
|
+
throw new GameServerError(`Can't reach the client dev server at ${this.clientUrl}: ${err.message}. Is it running?`);
|
|
3579
|
+
}
|
|
3580
|
+
const isJson = res.headers.get("content-type")?.includes("application/json");
|
|
3581
|
+
const body = isJson ? await res.json().catch(() => null) : null;
|
|
3582
|
+
if (!isJson || res.status === 404 && !body?.error) {
|
|
3583
|
+
throw new GameServerError(`No dev bridge at ${this.clientUrl}. The game client must be served by Vite in dev with the mavonengine Vite plugin (@mavonengine/core/vite.config). If Vite runs on its own port, pass --client-url, e.g. http://localhost:5173`);
|
|
3584
|
+
}
|
|
3585
|
+
if (res.status === 401 || res.status === 403)
|
|
3586
|
+
throw new GameServerError(`${body?.error ?? "Not allowed"}: pass --token or set MAVONENGINE_METRICS_TOKEN for both the CLI and the dev server`);
|
|
3587
|
+
if (!res.ok)
|
|
3588
|
+
throw new GameServerError(body?.error ?? `Client dev server responded ${res.status} ${res.statusText}`);
|
|
3589
|
+
return body;
|
|
3590
|
+
}
|
|
3591
|
+
async get(path, params = {}, notFound) {
|
|
3592
|
+
const target = new URL(path, this.base);
|
|
3593
|
+
for (const [key, value] of Object.entries(params))
|
|
3594
|
+
target.searchParams.set(key, value);
|
|
3595
|
+
let res;
|
|
3596
|
+
try {
|
|
3597
|
+
res = await this.fetchImpl(target, {
|
|
3598
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
|
3599
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
3600
|
+
});
|
|
3601
|
+
} catch (err) {
|
|
3602
|
+
throw new GameServerError(`Can't reach the game server at ${this.url}: ${err.message}. Is it running?`);
|
|
3603
|
+
}
|
|
3604
|
+
if (res.status === 401)
|
|
3605
|
+
throw new GameServerError("Metrics token missing or wrong: pass --token or set MAVONENGINE_METRICS_TOKEN");
|
|
3606
|
+
if (res.status === 403)
|
|
3607
|
+
throw new GameServerError("The game server only answers localhost without a token: pass --token or set MAVONENGINE_METRICS_TOKEN");
|
|
3608
|
+
if (res.status === 404)
|
|
3609
|
+
throw new GameServerError(notFound ?? "Metrics endpoints not found: they are turned off in the server options, or it runs in production without a metrics token");
|
|
3610
|
+
if (res.status === 400)
|
|
3611
|
+
throw new GameServerError(`Bad request: ${(await res.json().catch(() => null))?.error ?? res.statusText}`);
|
|
3612
|
+
if (!res.ok)
|
|
3613
|
+
throw new GameServerError(`Game server responded ${res.status} ${res.statusText}`);
|
|
3614
|
+
return res.json();
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
|
|
3618
|
+
// src/commands/mcp.ts
|
|
3619
|
+
var MCP_HELP = `Usage: mavonengine mcp [options]
|
|
3620
|
+
|
|
3621
|
+
Model Context Protocol (MCP) server over stdio, for any MCP client and model
|
|
3622
|
+
(Claude Code, Cursor, VS Code, Codex, Gemini CLI, Zed, local models, \u2026).
|
|
3623
|
+
It lets the assistant read a running game: the game server (status, metrics,
|
|
3624
|
+
players, scene, entities, logs, HTTP requests, plugins) and the game tabs open
|
|
3625
|
+
in a browser (renderer stats, entities, network, console, screenshots), compare
|
|
3626
|
+
an entity on both sides, and see which official plugins the project could add.
|
|
3627
|
+
Every tool is read-only. Server scene and entity tools
|
|
3628
|
+
need the game server in development (NODE_ENV is not "production"); client
|
|
3629
|
+
tools need the client served by Vite in dev.
|
|
3630
|
+
|
|
3631
|
+
Register it in your client as a stdio server with this command:
|
|
3632
|
+
npx -y @mavonengine/cli mcp [options]
|
|
3633
|
+
|
|
3634
|
+
Most clients take JSON like this (projects from 'mavonengine create' get it in .mcp.json):
|
|
3635
|
+
{ "mcpServers": { "mavonengine": { "command": "npx", "args": ["-y", "@mavonengine/cli", "mcp"] } } }
|
|
3636
|
+
|
|
3637
|
+
Options:
|
|
3638
|
+
--host <host> Server host (default: localhost)
|
|
3639
|
+
--port <port> Server HTTP port (default: 8050)
|
|
3640
|
+
--url <url> Server base URL, e.g. http://localhost:8050, overrides --host and --port
|
|
3641
|
+
--client-url <url> Where Vite serves the client in dev (default: the server URL,
|
|
3642
|
+
which serves it in the template), e.g. http://localhost:5173
|
|
3643
|
+
--token <token> Metrics or admin token (or set MAVONENGINE_METRICS_TOKEN
|
|
3644
|
+
or MAVONENGINE_ADMIN_TOKEN)
|
|
3645
|
+
--docs-url <url> MCP server of the documentation for the docs_* tools
|
|
3646
|
+
(default: https://mavonengine.com/mcp)
|
|
3647
|
+
--no-docs Leave out the docs_* tools, e.g. offline
|
|
3648
|
+
--plugins-url <url> List of plugins that can be installed, for available_plugins and
|
|
3649
|
+
plugin_readme (default: plugins.json on the main branch of the
|
|
3650
|
+
MavonEngine repository, or MAVONENGINE_PLUGINS_URL)
|
|
3651
|
+
--no-plugin-list Leave out available_plugins and plugin_readme, e.g. offline
|
|
3652
|
+
-h, --help Show this help
|
|
3653
|
+
`;
|
|
3654
|
+
function parseMcpArgs(args, env = process9.env) {
|
|
3655
|
+
const { values } = parseArgs6({
|
|
3656
|
+
args,
|
|
3657
|
+
options: {
|
|
3658
|
+
"host": { type: "string", default: "localhost" },
|
|
3659
|
+
"port": { type: "string", default: "8050" },
|
|
3660
|
+
"url": { type: "string" },
|
|
3661
|
+
"client-url": { type: "string" },
|
|
3662
|
+
"token": { type: "string" },
|
|
3663
|
+
"docs-url": { type: "string" },
|
|
3664
|
+
"docs": { type: "boolean", default: true },
|
|
3665
|
+
"plugins-url": { type: "string" },
|
|
3666
|
+
"plugin-list": { type: "boolean", default: true },
|
|
3667
|
+
"help": { type: "boolean", short: "h", default: false }
|
|
3668
|
+
},
|
|
3669
|
+
strict: true,
|
|
3670
|
+
allowPositionals: false,
|
|
3671
|
+
allowNegative: true
|
|
3672
|
+
});
|
|
3673
|
+
if (values.help)
|
|
3674
|
+
return "help";
|
|
3675
|
+
let url = values.url;
|
|
3676
|
+
if (!url) {
|
|
3677
|
+
const port = Number(values.port);
|
|
3678
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
3679
|
+
throw new UsageError("--port must be between 1 and 65535");
|
|
3680
|
+
const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
|
|
3681
|
+
url = `http://${host}:${port}`;
|
|
3682
|
+
}
|
|
3683
|
+
for (const [flag, value] of [["--url", url], ["--client-url", values["client-url"]], ["--docs-url", values["docs-url"]], ["--plugins-url", values["plugins-url"]]]) {
|
|
3684
|
+
try {
|
|
3685
|
+
if (value !== void 0)
|
|
3686
|
+
void new URL(value);
|
|
3687
|
+
} catch {
|
|
3688
|
+
throw new UsageError(`Invalid ${flag}: ${value}`);
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
return {
|
|
3692
|
+
client: new GameServerClient({ url, clientUrl: values["client-url"], token: values.token ?? env.MAVONENGINE_METRICS_TOKEN ?? env.MAVONENGINE_ADMIN_TOKEN }),
|
|
3693
|
+
docsUrl: values.docs ? values["docs-url"] ?? DEFAULT_DOCS_URL : false,
|
|
3694
|
+
pluginsUrl: values["plugin-list"] ? values["plugins-url"] ?? env[PLUGIN_REGISTRY_ENV] ?? PLUGIN_REGISTRY_URL : false
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
async function runMcpCommand({ client, docsUrl, pluginsUrl }) {
|
|
3698
|
+
const [{ StdioServerTransport }, { createMcpServer }, { DocsClient }] = await Promise.all([
|
|
3699
|
+
import("@modelcontextprotocol/sdk/server/stdio.js"),
|
|
3700
|
+
import("./server-NNL4IMYV.js"),
|
|
3701
|
+
import("./docs-O2V2WSUG.js")
|
|
3702
|
+
]);
|
|
3703
|
+
const docs = docsUrl ? new DocsClient(docsUrl) : void 0;
|
|
3704
|
+
const server = createMcpServer(client, docs, pluginsUrl ? { url: pluginsUrl } : void 0);
|
|
3705
|
+
const transport = new StdioServerTransport();
|
|
3706
|
+
const closed = new Promise((resolve7) => {
|
|
3707
|
+
transport.onclose = resolve7;
|
|
3708
|
+
process9.stdin.on("end", resolve7);
|
|
3709
|
+
});
|
|
3710
|
+
await server.connect(transport);
|
|
3711
|
+
process9.stderr.write(`mavonengine mcp: reading the game server at ${client.url}
|
|
3712
|
+
`);
|
|
3713
|
+
await closed;
|
|
3714
|
+
await server.close().catch(() => {
|
|
3715
|
+
});
|
|
3716
|
+
await docs?.close();
|
|
3717
|
+
return 0;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
// src/commands/plugins.ts
|
|
3721
|
+
import { resolve as resolve6 } from "path";
|
|
3722
|
+
import process10 from "process";
|
|
3723
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
3724
|
+
var PLUGINS_HELP = `Usage: mavonengine plugins [options]
|
|
3725
|
+
|
|
3726
|
+
List the plugins that are available to install. The list is plugins.json on the main branch
|
|
3727
|
+
of the MavonEngine repository, read from GitHub every time:
|
|
3728
|
+
${PLUGIN_REGISTRY_URL}
|
|
3729
|
+
|
|
3730
|
+
Inside a project it also says which of them the project already has in ${CONFIG_FILE}.
|
|
3731
|
+
|
|
3732
|
+
Options:
|
|
3733
|
+
--url <url> Read the list from here instead (also ${PLUGIN_REGISTRY_ENV})
|
|
3734
|
+
--json Print the list as JSON
|
|
3735
|
+
--cwd <dir> Look for the project here (default: current directory)
|
|
3736
|
+
-h, --help Show this help
|
|
3737
|
+
`;
|
|
3738
|
+
function parsePluginsArgs(args, cwd = process10.cwd(), env = process10.env) {
|
|
3739
|
+
const { values, positionals } = parseArgs7({
|
|
3740
|
+
args,
|
|
3741
|
+
options: {
|
|
3742
|
+
url: { type: "string" },
|
|
3743
|
+
json: { type: "boolean", default: false },
|
|
3744
|
+
cwd: { type: "string" },
|
|
3745
|
+
help: { type: "boolean", short: "h", default: false }
|
|
3746
|
+
},
|
|
3747
|
+
strict: true,
|
|
3748
|
+
allowPositionals: true
|
|
3749
|
+
});
|
|
3750
|
+
if (values.help)
|
|
3751
|
+
return "help";
|
|
3752
|
+
if (positionals.length > 0)
|
|
3753
|
+
throw new UsageError(`unexpected argument '${positionals[0]}'
|
|
3754
|
+
|
|
3755
|
+
${PLUGINS_HELP}`);
|
|
3756
|
+
return {
|
|
3757
|
+
cwd: resolve6(cwd, values.cwd ?? "."),
|
|
3758
|
+
url: values.url ?? env[PLUGIN_REGISTRY_ENV] ?? PLUGIN_REGISTRY_URL,
|
|
3759
|
+
json: values.json
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
function projectPlugins(cwd) {
|
|
3763
|
+
const states = /* @__PURE__ */ new Map();
|
|
3764
|
+
const root = findConfigRoot(cwd);
|
|
3765
|
+
if (!root)
|
|
3766
|
+
return states;
|
|
3767
|
+
try {
|
|
3768
|
+
for (const [pkg, entry] of Object.entries(readConfig(root).plugins))
|
|
3769
|
+
states.set(pkg, entry?.enabled === false ? "disabled" : "enabled");
|
|
3770
|
+
} catch {
|
|
3771
|
+
}
|
|
3772
|
+
return states;
|
|
3773
|
+
}
|
|
3774
|
+
var TARGETS2 = { client: "client", server: "server", both: "client + server" };
|
|
3775
|
+
function formatPlugins(registry, inProject) {
|
|
3776
|
+
const { plugins } = registry;
|
|
3777
|
+
const lines = [`${plugins.length} plugin${plugins.length === 1 ? "" : "s"} available, from ${registry.url}`, ""];
|
|
3778
|
+
if (registry.version > SUPPORTED_REGISTRY_VERSION)
|
|
3779
|
+
lines.push("The list is in a newer format than this CLI knows, update @mavonengine/cli to see everything in it.", "");
|
|
3780
|
+
const width = Math.max(0, ...plugins.map((plugin) => plugin.package.length));
|
|
3781
|
+
for (const plugin of plugins) {
|
|
3782
|
+
const state = inProject.get(plugin.package);
|
|
3783
|
+
const facts = [
|
|
3784
|
+
plugin.target ? `runs on ${TARGETS2[plugin.target]}` : void 0,
|
|
3785
|
+
plugin.engine ? `@mavonengine/core ${plugin.engine}` : void 0,
|
|
3786
|
+
state === "enabled" ? "in this project" : state === "disabled" ? "in this project, disabled" : void 0
|
|
3787
|
+
].filter(Boolean);
|
|
3788
|
+
lines.push(` ${plugin.package.padEnd(width)} ${plugin.name === plugin.package ? "" : plugin.name}`.trimEnd());
|
|
3789
|
+
if (plugin.description)
|
|
3790
|
+
lines.push(` ${plugin.description}`);
|
|
3791
|
+
if (facts.length)
|
|
3792
|
+
lines.push(` ${facts.join(" \xB7 ")}`);
|
|
3793
|
+
if (plugin.homepage)
|
|
3794
|
+
lines.push(` ${plugin.homepage}`);
|
|
3795
|
+
lines.push("");
|
|
3796
|
+
}
|
|
3797
|
+
lines.push(plugins.length ? "Install one with: mavonengine install <package>" : "The list is empty.");
|
|
3798
|
+
return `${lines.join("\n")}
|
|
3799
|
+
`;
|
|
3800
|
+
}
|
|
3801
|
+
async function runPluginsCommand(options, io = { write: (text) => process10.stdout.write(text) }) {
|
|
3802
|
+
const registry = await fetchPluginRegistry({ url: options.url, fetch: io.fetch });
|
|
3803
|
+
if (options.json) {
|
|
3804
|
+
io.write(`${JSON.stringify(registry, null, 2)}
|
|
3805
|
+
`);
|
|
3806
|
+
return 0;
|
|
3807
|
+
}
|
|
3808
|
+
io.write(formatPlugins(registry, projectPlugins(options.cwd)));
|
|
3809
|
+
return 0;
|
|
3810
|
+
}
|
|
3811
|
+
|
|
1767
3812
|
// src/index.ts
|
|
1768
3813
|
var HELP = `Usage: mavonengine <command> [options]
|
|
1769
3814
|
|
|
@@ -1771,6 +3816,10 @@ Commands:
|
|
|
1771
3816
|
create Create a project from the multiplayer template and start it
|
|
1772
3817
|
dashboard Live terminal dashboard for a running game server
|
|
1773
3818
|
dev Run the project's dev servers (game server and client)
|
|
3819
|
+
install Install packages and register engine plugins in mavonengine.json
|
|
3820
|
+
plugins List the plugins that are available to install
|
|
3821
|
+
build Build the project for release, with the plugin assets it uses
|
|
3822
|
+
mcp MCP server so AI assistants (any MCP client) can inspect a running game server
|
|
1774
3823
|
|
|
1775
3824
|
Options:
|
|
1776
3825
|
-h, --help Show help
|
|
@@ -1781,18 +3830,18 @@ Run 'mavonengine <command> --help' for command options.
|
|
|
1781
3830
|
async function main(argv) {
|
|
1782
3831
|
const [command, ...args] = argv;
|
|
1783
3832
|
if (!command || command === "-h" || command === "--help" || command === "help") {
|
|
1784
|
-
|
|
3833
|
+
process11.stdout.write(HELP);
|
|
1785
3834
|
return 0;
|
|
1786
3835
|
}
|
|
1787
3836
|
if (command === "-v" || command === "--version") {
|
|
1788
|
-
|
|
3837
|
+
process11.stdout.write(`${version}
|
|
1789
3838
|
`);
|
|
1790
3839
|
return 0;
|
|
1791
3840
|
}
|
|
1792
3841
|
if (command === "create") {
|
|
1793
3842
|
const options = parseCreateArgs(args);
|
|
1794
3843
|
if (options === "help") {
|
|
1795
|
-
|
|
3844
|
+
process11.stdout.write(CREATE_HELP);
|
|
1796
3845
|
return 0;
|
|
1797
3846
|
}
|
|
1798
3847
|
return runCreateCommand(options);
|
|
@@ -1800,11 +3849,11 @@ async function main(argv) {
|
|
|
1800
3849
|
if (command === "dashboard") {
|
|
1801
3850
|
const options = parseDashboardArgs(args);
|
|
1802
3851
|
if (options === "help") {
|
|
1803
|
-
|
|
3852
|
+
process11.stdout.write(DASHBOARD_HELP);
|
|
1804
3853
|
return 0;
|
|
1805
3854
|
}
|
|
1806
|
-
if (!
|
|
1807
|
-
|
|
3855
|
+
if (!process11.stdout.isTTY || !process11.stdin.isTTY) {
|
|
3856
|
+
process11.stderr.write("mavonengine dashboard needs an interactive terminal\n");
|
|
1808
3857
|
return 1;
|
|
1809
3858
|
}
|
|
1810
3859
|
await runDashboard(options);
|
|
@@ -1813,18 +3862,50 @@ async function main(argv) {
|
|
|
1813
3862
|
if (command === "dev") {
|
|
1814
3863
|
const options = parseDevArgs(args);
|
|
1815
3864
|
if (options === "help") {
|
|
1816
|
-
|
|
3865
|
+
process11.stdout.write(DEV_HELP);
|
|
1817
3866
|
return 0;
|
|
1818
3867
|
}
|
|
1819
3868
|
return runDevCommand(options);
|
|
1820
3869
|
}
|
|
3870
|
+
if (command === "install" || command === "i" || command === "add") {
|
|
3871
|
+
const options = parseInstallArgs(args);
|
|
3872
|
+
if (options === "help") {
|
|
3873
|
+
process11.stdout.write(INSTALL_HELP);
|
|
3874
|
+
return 0;
|
|
3875
|
+
}
|
|
3876
|
+
return runInstallCommand(options);
|
|
3877
|
+
}
|
|
3878
|
+
if (command === "plugins") {
|
|
3879
|
+
const options = parsePluginsArgs(args);
|
|
3880
|
+
if (options === "help") {
|
|
3881
|
+
process11.stdout.write(PLUGINS_HELP);
|
|
3882
|
+
return 0;
|
|
3883
|
+
}
|
|
3884
|
+
return runPluginsCommand(options);
|
|
3885
|
+
}
|
|
3886
|
+
if (command === "mcp") {
|
|
3887
|
+
const options = parseMcpArgs(args);
|
|
3888
|
+
if (options === "help") {
|
|
3889
|
+
process11.stdout.write(MCP_HELP);
|
|
3890
|
+
return 0;
|
|
3891
|
+
}
|
|
3892
|
+
return runMcpCommand(options);
|
|
3893
|
+
}
|
|
3894
|
+
if (command === "build") {
|
|
3895
|
+
const options = parseBuildArgs(args);
|
|
3896
|
+
if (options === "help") {
|
|
3897
|
+
process11.stdout.write(BUILD_HELP);
|
|
3898
|
+
return 0;
|
|
3899
|
+
}
|
|
3900
|
+
return runBuildCommand(options);
|
|
3901
|
+
}
|
|
1821
3902
|
throw new UsageError(`Unknown command '${command}'
|
|
1822
3903
|
|
|
1823
3904
|
${HELP}`);
|
|
1824
3905
|
}
|
|
1825
|
-
main(
|
|
3906
|
+
main(process11.argv.slice(2)).then((code) => process11.exit(code)).catch((err) => {
|
|
1826
3907
|
const isUsage = err instanceof UsageError || err.code?.startsWith("ERR_PARSE_ARGS");
|
|
1827
|
-
|
|
3908
|
+
process11.stderr.write(`mavonengine: ${err.message}
|
|
1828
3909
|
`);
|
|
1829
|
-
|
|
3910
|
+
process11.exit(isUsage ? 2 : 1);
|
|
1830
3911
|
});
|