@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/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 process6 from "process";
5
-
6
- // package.json
7
- var version = "0.0.1";
17
+ import process11 from "process";
8
18
 
9
- // src/commands/create.ts
10
- import { existsSync as existsSync3 } from "fs";
11
- import process5 from "process";
12
- import { createInterface } from "readline/promises";
13
- import { parseArgs as parseArgs3 } from "util";
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
- // src/create/project.ts
16
- import { spawn } from "child_process";
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
- import process from "process";
21
- import { Readable, Transform } from "stream";
22
- import { pipeline } from "stream/promises";
23
- import AdmZip from "adm-zip";
24
-
25
- // src/dashboard/format.ts
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 formatRate(bytesPerSecond) {
35
- return `${formatBytes(bytesPerSecond)}/s`;
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 formatDuration(totalSeconds) {
38
- const seconds = Math.max(0, Math.floor(totalSeconds));
39
- const d = Math.floor(seconds / 86400);
40
- const h = Math.floor(seconds % 86400 / 3600);
41
- const m = Math.floor(seconds % 3600 / 60);
42
- const s = seconds % 60;
43
- if (d > 0)
44
- return `${d}d ${h}h ${m}m`;
45
- if (h > 0)
46
- return `${h}h ${m}m ${s}s`;
47
- if (m > 0)
48
- return `${m}m ${s}s`;
49
- return `${s}s`;
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 formatMs(ms) {
52
- return `${ms.toFixed(ms < 10 ? 2 : 1)} ms`;
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 formatPercent(value) {
55
- return `${value.toFixed(1)}%`;
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
- // src/create/project.ts
59
- var TEMPLATE_URL = "https://github.com/MavonEngine/Core/archive/refs/tags/0.0.15-alpha.zip";
60
- var TEMPLATE_SUBPATH = "packages/multiplayer-template";
61
- var PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/;
62
- function projectDirFor(projectName, parentDir) {
63
- return resolve(parentDir, projectName);
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
- async function downloadZip(url, { report, signal, fetch = globalThis.fetch }) {
66
- const response = await fetch(url, { signal });
67
- if (!response.ok || !response.body)
68
- throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
69
- const total = Number(response.headers.get("content-length")) || 0;
70
- let received = 0;
71
- const progress = new Transform({
72
- transform(chunk, _encoding, callback) {
73
- received += chunk.length;
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 dest;
102
+ return manifest;
86
103
  }
87
- function extractTemplate(zipPath, templateSubpath, destDir) {
88
- const entries = new AdmZip(zipPath).getEntries();
89
- if (entries.length === 0)
90
- throw new Error("Downloaded zip archive is empty");
91
- const rootFolder = entries[0].entryName.split("/")[0];
92
- const templatePrefix = `${rootFolder}/${templateSubpath}/`;
93
- const templateEntries = entries.filter((e) => e.entryName.startsWith(templatePrefix));
94
- if (templateEntries.length === 0)
95
- throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`);
96
- const root = resolve(destDir);
97
- let files = 0;
98
- for (const entry of templateEntries) {
99
- const relativePath = entry.entryName.slice(templatePrefix.length);
100
- if (!relativePath)
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
- const outputPath = resolve(root, relativePath);
103
- const inside = relative(root, outputPath);
104
- if (inside.startsWith("..") || isAbsolute(inside))
105
- throw new Error(`Refusing to extract ${entry.entryName} outside the project`);
106
- if (entry.isDirectory) {
107
- mkdirSync(outputPath, { recursive: true });
108
- } else {
109
- mkdirSync(dirname(outputPath), { recursive: true });
110
- writeFileSync(outputPath, entry.getData());
111
- files++;
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 files;
182
+ return { assets, warnings };
115
183
  }
116
- function runCommand(command, args, cwd, { report, signal }) {
117
- return new Promise((resolvePromise, reject) => {
118
- const child = spawn(command, args, {
119
- cwd,
120
- stdio: ["ignore", "pipe", "pipe"],
121
- // npm is a .cmd shim on Windows
122
- shell: process.platform === "win32",
123
- signal
124
- });
125
- child.stdout.setEncoding("utf8").on("data", report.output);
126
- child.stderr.setEncoding("utf8").on("data", report.output);
127
- child.on("error", (err) => {
128
- reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
129
- });
130
- child.on("exit", (code, exitSignal) => {
131
- if (code === 0)
132
- resolvePromise();
133
- else
134
- reject(new Error(`'${command} ${args.join(" ")}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`));
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
- async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }) {
139
- const projectDir = projectDirFor(projectName, parentDir);
140
- if (existsSync(projectDir))
141
- throw new Error(`Directory "${projectDir}" already exists`);
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
- report.step("Extracting the template\u2026");
151
- const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir);
152
- report.output(`Extracted ${files} files
153
- `);
154
- } finally {
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
- const pkgJsonPath = join(projectDir, "package.json");
158
- if (existsSync(pkgJsonPath)) {
159
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
160
- pkg.name = projectName;
161
- writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}
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
- if (install) {
165
- signal?.throwIfAborted();
166
- report.step("Installing dependencies\u2026");
167
- report.output("Running 'npm install'\n");
168
- await runCommand("npm", ["install"], projectDir, { report, signal });
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
- return projectDir;
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.listeners.forEach((listener) => listener(this.state));
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
- * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs.
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
- nextDelay() {
469
- if (this.startupIntervalMs !== void 0 && !this.state.snapshot)
470
- return this.startupIntervalMs;
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
- var DASHBOARD_TABS = ["Overview", "Players", "Logs", "HTTP"];
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
- // src/commands/dashboard.ts
1274
- var DASHBOARD_HELP = `Usage: mavonengine dashboard [options]
1275
-
1276
- Live terminal dashboard for a running MavonEngine game server.
1277
-
1278
- Options:
1279
- --host <host> Server host (default: localhost)
1280
- --port <port> Server HTTP port (default: 8050)
1281
- --url <url> Full metrics URL, overrides --host and --port
1282
- --token <token> Metrics token (or set MAVONENGINE_METRICS_TOKEN)
1283
- --interval <ms> Poll interval in ms (default: 1000)
1284
- --theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix, light, \u2026
1285
- --transparent Let a transparent terminal background show through
1286
- (text can be hard to read over a light wallpaper)
1287
- --sim Show simulated data instead of connecting to a server
1288
- -h, --help Show this help
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 (values.help)
1311
- return "help";
1312
- const intervalMs = Number(values.interval);
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
- try {
1326
- void new URL(url);
1327
- } catch {
1328
- throw new UsageError(`Invalid --url: ${url}`);
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
- const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN;
1331
- return { source: httpSource({ url, token }), clientUrl: new URL("/", url).href, intervalMs, theme: values.theme, transparent: values.transparent };
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
- async function runDashboard(options, signal) {
1334
- const { createApp } = await import("@profullstack/hqtui");
1335
- const client = new MetricsClient(options.source, {
1336
- intervalMs: options.intervalMs,
1337
- startupIntervalMs: options.startup ? STARTUP_POLL_MS : void 0
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
- const view = { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true };
1340
- const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent });
1341
- app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, {
1342
- transparent: options.transparent,
1343
- startup: options.startup?.(),
1344
- openKey: !!options.clientUrl
1345
- }));
1346
- client.onChange(() => app.invalidate());
1347
- const { clientUrl } = options;
1348
- if (clientUrl && options.openBrowser) {
1349
- const off = client.onChange(async (state) => {
1350
- if (state.status !== "live")
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
- off();
1353
- if (await servesPage(clientUrl))
1354
- openBrowser(clientUrl);
1355
- });
1356
- }
1357
- app.on("key", (event) => {
1358
- if (event.key === "r")
1359
- void client.refresh();
1360
- if (event.key === "o" && options.clientUrl)
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
- if (handlePlayersKey(view, client.state.snapshot, event.key))
1368
- app.invalidate();
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 script of the MavonEngine project you are in (game server and client)
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 = process4.cwd(), env = process4.env) {
1424
- const { values } = parseArgs2({
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: resolve2(cwd, values.cwd ?? "."), dashboard };
3002
+ return { cwd: resolve4(cwd, values.cwd ?? "."), dashboard };
1447
3003
  }
1448
3004
  function readPackageJson(dir) {
1449
- const path = join2(dir, "package.json");
1450
- if (!existsSync2(path))
3005
+ const path = join4(dir, "package.json");
3006
+ if (!existsSync4(path))
1451
3007
  return void 0;
1452
3008
  try {
1453
- return JSON.parse(readFileSync2(path, "utf8"));
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 = resolve2(start); ; dir = dirname2(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 && typeof pkg?.scripts?.dev === "string")
3035
+ if ("@mavonengine/core" in deps && devScriptOf(pkg))
1463
3036
  return dir;
1464
- if (dirname2(dir) === dir)
3037
+ if (dirname3(dir) === dir)
1465
3038
  return void 0;
1466
3039
  }
1467
3040
  }
1468
3041
  function installedEngineVersion(root) {
1469
- return readPackageJson(join2(root, "node_modules", "@mavonengine", "core"))?.version;
3042
+ return readPackageJson(join4(root, "node_modules", "@mavonengine", "core"))?.version;
1470
3043
  }
1471
3044
  function detectPackageManager(root) {
1472
- return LOCKFILES.find(([file]) => existsSync2(join2(root, file)))?.[1] ?? "npm";
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 (process4.platform === "win32")
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
- process4.kill(p, signal);
3077
+ process6.kill(p, signal);
1505
3078
  } catch {
1506
3079
  }
1507
3080
  }
1508
3081
  }
1509
3082
  function isAlive(pid) {
1510
3083
  try {
1511
- process4.kill(pid, 0);
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 (process4.platform === "win32") {
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 child = spawn3(pm, ["run", "dev"], {
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: process4.platform === "win32"
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 dev' in ${root}
3119
+ const announce = `mavonengine: running '${pm} run ${script}' in ${root}
1545
3120
  `;
1546
3121
  if (capture)
1547
3122
  keep(announce);
1548
3123
  else
1549
- process4.stdout.write(announce);
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
- process4.on("SIGINT", onInterrupt);
1558
- process4.on("SIGTERM", forward);
1559
- process4.on("SIGHUP", forward);
1560
- process4.on("exit", onExit);
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
- process4.off("SIGINT", onInterrupt);
1565
- process4.off("SIGTERM", forward);
1566
- process4.off("SIGHUP", forward);
1567
- process4.off("exit", onExit);
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 (process4.platform === "win32") {
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 runDev(root) {
1595
- return startDev(root).exited;
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 = startDev(root, { capture: true });
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
- process4.stderr.write(`${dev.output()}
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
- if (options.dashboard && process4.stdout.isTTY && process4.stdin.isTTY)
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 = process5.cwd(), env = process5.env) {
1646
- const { values, positionals } = parseArgs3({
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 = createInterface({ input: process5.stdin, output: process5.stdout });
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
- process5.stderr.write(`mavonengine: stopped creating the project, what was set up so far is in ${projectDirFor(projectName, parentDir)}
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
- process5.stderr.write(`${dev.output()}
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
- process5.stderr.write(output);
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) => process5.stdout.write(chunk)
3449
+ output: (chunk) => process8.stdout.write(chunk)
1741
3450
  };
1742
3451
  const dir = await createProject({ projectName, parentDir, report });
1743
- process5.stdout.write(`
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 = !!process5.stdout.isTTY && !!process5.stdin.isTTY;
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 (existsSync3(dir))
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
- process6.stdout.write(HELP);
3833
+ process11.stdout.write(HELP);
1785
3834
  return 0;
1786
3835
  }
1787
3836
  if (command === "-v" || command === "--version") {
1788
- process6.stdout.write(`${version}
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
- process6.stdout.write(CREATE_HELP);
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
- process6.stdout.write(DASHBOARD_HELP);
3852
+ process11.stdout.write(DASHBOARD_HELP);
1804
3853
  return 0;
1805
3854
  }
1806
- if (!process6.stdout.isTTY || !process6.stdin.isTTY) {
1807
- process6.stderr.write("mavonengine dashboard needs an interactive terminal\n");
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
- process6.stdout.write(DEV_HELP);
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(process6.argv.slice(2)).then((code) => process6.exit(code)).catch((err) => {
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
- process6.stderr.write(`mavonengine: ${err.message}
3908
+ process11.stderr.write(`mavonengine: ${err.message}
1828
3909
  `);
1829
- process6.exit(isUsage ? 2 : 1);
3910
+ process11.exit(isUsage ? 2 : 1);
1830
3911
  });