@mavonengine/cli 0.0.2 → 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,23 +1,30 @@
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";
2
9
  import {
3
10
  DEFAULT_DOCS_URL,
4
11
  MCP_CONFIG_FILE,
5
12
  version,
6
13
  writeMcpConfig
7
- } from "./chunk-TNN4S5K4.js";
14
+ } from "./chunk-XPKCYSMN.js";
8
15
 
9
16
  // src/index.ts
10
- import process9 from "process";
17
+ import process11 from "process";
11
18
 
12
19
  // src/commands/build.ts
13
- import { spawn as spawn3 } from "child_process";
14
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
15
- import { join as join3, resolve as resolve3 } from "path";
16
- import process4 from "process";
17
- import { parseArgs as parseArgs3 } from "util";
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";
18
25
 
19
26
  // ../core/dist/Plugins/ProjectConfig.js
20
- import { existsSync, readFileSync, writeFileSync } from "fs";
27
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
21
28
  import { dirname, isAbsolute, join, relative, resolve } from "path";
22
29
  var CONFIG_FILE = "mavonengine.json";
23
30
  var TARGETS = ["client", "server", "both"];
@@ -26,6 +33,14 @@ var PluginConfigError = class extends Error {
26
33
  function configPath(root) {
27
34
  return join(root, CONFIG_FILE);
28
35
  }
36
+ function findConfigRoot(start) {
37
+ for (let dir = resolve(start); ; dir = dirname(dir)) {
38
+ if (existsSync(configPath(dir)))
39
+ return dir;
40
+ if (dirname(dir) === dir)
41
+ return void 0;
42
+ }
43
+ }
29
44
  function readConfig(root) {
30
45
  const path = configPath(root);
31
46
  if (!existsSync(path))
@@ -168,7 +183,7 @@ function releaseAssets(root) {
168
183
  }
169
184
 
170
185
  // src/commands/dashboard.ts
171
- import process2 from "process";
186
+ import process3 from "process";
172
187
  import { parseArgs } from "util";
173
188
 
174
189
  // src/browser.ts
@@ -205,6 +220,39 @@ async function servesPage(url, { timeoutMs = 3e3, fetch = globalThis.fetch } = {
205
220
  }
206
221
  }
207
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 };
229
+ }
230
+ fetchRegistry;
231
+ state;
232
+ listeners = /* @__PURE__ */ new Set();
233
+ onChange(listener) {
234
+ this.listeners.add(listener);
235
+ return () => this.listeners.delete(listener);
236
+ }
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
+ };
255
+
208
256
  // src/dashboard/snapshot.ts
209
257
  var SUPPORTED_SCHEMA_VERSION = 1;
210
258
  function isRecord(value) {
@@ -335,10 +383,108 @@ function normalizeRequests(raw) {
335
383
  latestId: num(r.latestId, entries.at(-1)?.id ?? 0)
336
384
  };
337
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
+ }
338
483
 
339
484
  // src/dashboard/MetricsClient.ts
340
485
  var MAX_LOG_LINES = 1e3;
341
486
  var MAX_REQUEST_LINES = 1e3;
487
+ var MAX_PACKET_LINES = 2e3;
342
488
  var MetricsSourceError = class extends Error {
343
489
  constructor(message, status) {
344
490
  super(message);
@@ -349,6 +495,11 @@ var MetricsSourceError = class extends Error {
349
495
  function httpSource({ url, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
350
496
  const logsUrl = new URL("logs", url);
351
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);
352
503
  async function request(target) {
353
504
  let res;
354
505
  try {
@@ -379,7 +530,15 @@ function httpSource({ url, token, timeoutMs = 3e3, fetch = globalThis.fetch }) {
379
530
  fetchRequests: (afterId) => {
380
531
  requestsUrl.searchParams.set("after", String(afterId));
381
532
  return request(requestsUrl.toString());
382
- }
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())
383
542
  };
384
543
  }
385
544
  var Feed = class {
@@ -418,13 +577,16 @@ var Feed = class {
418
577
  }
419
578
  };
420
579
  var MetricsClient = class {
421
- constructor(source, { intervalMs = 1e3, maxBackoffMs = 1e4, startupIntervalMs } = {}) {
580
+ constructor(source, { intervalMs = 1e3, maxBackoffMs = 1e4, startupIntervalMs, routesIntervalMs = 5e3 } = {}) {
422
581
  this.source = source;
423
582
  this.intervalMs = intervalMs;
583
+ this.routesIntervalMs = routesIntervalMs;
424
584
  this.maxBackoffMs = maxBackoffMs;
425
585
  this.startupIntervalMs = startupIntervalMs;
426
586
  this.logs = new Feed(source.fetchLogs?.bind(source), normalizeLogs, MAX_LOG_LINES);
427
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;
428
590
  this.state = {
429
591
  status: "connecting",
430
592
  source: source.label,
@@ -435,7 +597,15 @@ var MetricsClient = class {
435
597
  logs: [],
436
598
  logsAvailable: this.logs.available,
437
599
  requests: [],
438
- 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
439
609
  };
440
610
  }
441
611
  source;
@@ -448,6 +618,46 @@ var MetricsClient = class {
448
618
  listeners = /* @__PURE__ */ new Set();
449
619
  logs;
450
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
+ }
451
661
  onChange(listener) {
452
662
  this.listeners.add(listener);
453
663
  return () => this.listeners.delete(listener);
@@ -471,6 +681,7 @@ var MetricsClient = class {
471
681
  if (this.timer)
472
682
  clearTimeout(this.timer);
473
683
  this.timer = null;
684
+ this.projectFetchedAt = null;
474
685
  await this.loop();
475
686
  }
476
687
  /**
@@ -484,17 +695,71 @@ var MetricsClient = class {
484
695
  this.state.lastUpdate = Date.now();
485
696
  this.state.error = null;
486
697
  this.state.failures = 0;
487
- await Promise.all([this.logs.poll(), this.requests.poll()]);
698
+ await Promise.all([this.logs.poll(), this.requests.poll(), this.pollProject()]);
488
699
  this.state.logs = this.logs.entries;
489
700
  this.state.logsAvailable = this.logs.available;
490
701
  this.state.requests = this.requests.entries;
491
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
+ }
492
708
  } catch (err) {
493
709
  this.state.status = err instanceof MetricsSourceError ? err.status : "unreachable";
494
710
  this.state.error = err.message;
495
711
  this.state.failures++;
496
712
  }
497
- 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
+ }
726
+ }
727
+ /**
728
+ * Fetch the server's HTTP endpoints and plugins, unless they were fetched a moment ago
729
+ */
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
+ }
498
763
  }
499
764
  /**
500
765
  * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs.
@@ -520,6 +785,48 @@ var MetricsClient = class {
520
785
 
521
786
  // src/dashboard/simulator.ts
522
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
+ ];
523
830
  function mulberry32(seed) {
524
831
  return () => {
525
832
  seed |= 0;
@@ -554,6 +861,15 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
554
861
  const request = (method, path, status, bytes, address) => {
555
862
  requests.push({ id: requests.length + 1, timestamp: now, method, path, status, durationMs: Math.round((1 + random() * 20) * 100) / 100, bytes, address });
556
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
+ };
557
873
  log("info", "Starting @mavon/engine - Socket Server");
558
874
  log("info", "Starting @mavon/engine - HTTP Server");
559
875
  return {
@@ -591,14 +907,48 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
591
907
  request("GET", "/favicon.ico", 404, 160, address);
592
908
  request("POST", "/.wrtc/v2/connections", 200, 2900, address);
593
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 } });
594
913
  log("info", `Connected: ${players[i].id}`);
595
914
  log("info", `${i + 1} total players connected`);
596
915
  }
597
916
  for (let i = previousPlayers - 1; i >= playerCount; i--) {
917
+ record({ kind: "event", type: "disconnect", peer: `sim-${(i + 1).toString().padStart(2, "0")}` });
598
918
  log("info", `Disconnected sim-${(i + 1).toString().padStart(2, "0")}`);
599
919
  log("info", `${i} total players connected`);
600
920
  }
601
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
+ }));
602
952
  if (step % 10 === 0)
603
953
  request("GET", "/api/game/health", 200, 180, "198.51.100.7");
604
954
  if (step % 23 === 0)
@@ -671,6 +1021,47 @@ function simulatedSource(seed = 1, sampleIntervalMs = 1e3) {
671
1021
  },
672
1022
  async fetchRequests(afterId) {
673
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 };
674
1065
  }
675
1066
  };
676
1067
  }
@@ -834,7 +1225,14 @@ var LOGO_SIZES = [
834
1225
  ];
835
1226
 
836
1227
  // src/dashboard/view.ts
837
- 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;
838
1236
  var WIDE_LAYOUT = 100;
839
1237
  var NEWLINE_RE = /\r?\n/;
840
1238
  var STATUS_LABELS = {
@@ -870,6 +1268,123 @@ function handlePlayersKey(view, snapshot, key) {
870
1268
  view.followSelectedPlayer = true;
871
1269
  return true;
872
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
+ }
873
1388
  function statusColor(status, theme) {
874
1389
  if (status === "live")
875
1390
  return theme.success;
@@ -877,7 +1392,7 @@ function statusColor(status, theme) {
877
1392
  return theme.warning;
878
1393
  return theme.danger;
879
1394
  }
880
- 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 } = {}) {
881
1396
  const { snapshot } = state;
882
1397
  const starting = !snapshot && !!startup && (state.status === "connecting" || state.status === "unreachable");
883
1398
  ui.column({}, (root) => {
@@ -907,6 +1422,12 @@ function renderDashboard(ui, theme, state, view, { transparent = false, startup,
907
1422
  logsPanel(root, theme, state, view);
908
1423
  } else if (tab === "HTTP") {
909
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);
910
1431
  } else {
911
1432
  root.responsive({
912
1433
  [WIDE_LAYOUT]: (c) => wideLayout(c, theme, state, snapshot, view, transparent),
@@ -922,7 +1443,14 @@ function renderDashboard(ui, theme, state, view, { transparent = false, startup,
922
1443
  { key: "r", label: "Refresh" },
923
1444
  { key: `1-${DASHBOARD_TABS.length}`, label: "Tabs" },
924
1445
  ...openKey && snapshot ? [{ key: "o", label: "Open game" }] : [],
925
- ...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) : []
926
1454
  ],
927
1455
  right: [starting ? { label: "STARTING", color: statusColor("connecting", theme) } : { label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === "live" }]
928
1456
  });
@@ -1299,129 +1827,1121 @@ function requestsPanel(ui, theme, state, view, transparent) {
1299
1827
  });
1300
1828
  });
1301
1829
  }
1302
-
1303
- // src/commands/dashboard.ts
1304
- var DASHBOARD_HELP = `Usage: mavonengine dashboard [options]
1305
-
1306
- Live terminal dashboard for a running MavonEngine game server.
1307
-
1308
- Options:
1309
- --host <host> Server host (default: localhost)
1310
- --port <port> Server HTTP port (default: 8050)
1311
- --url <url> Full metrics URL, overrides --host and --port
1312
- --token <token> Metrics token (or set MAVONENGINE_METRICS_TOKEN)
1313
- --interval <ms> Poll interval in ms (default: 1000)
1314
- --theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix, light, \u2026
1315
- --transparent Let a transparent terminal background show through
1316
- (text can be hard to read over a light wallpaper)
1317
- --sim Show simulated data instead of connecting to a server
1318
- -h, --help Show this help
1319
- `;
1320
- var STARTUP_POLL_MS = 250;
1321
- var UsageError = class extends Error {
1322
- };
1323
- function parseDashboardArgs(args, env = process2.env) {
1324
- const { values } = parseArgs({
1325
- args,
1326
- options: {
1327
- host: { type: "string", default: "localhost" },
1328
- port: { type: "string", default: "8050" },
1329
- url: { type: "string" },
1330
- token: { type: "string" },
1331
- interval: { type: "string", default: "1000" },
1332
- theme: { type: "string" },
1333
- transparent: { type: "boolean", default: false },
1334
- sim: { type: "boolean", default: false },
1335
- help: { type: "boolean", short: "h", default: false }
1336
- },
1337
- strict: true,
1338
- allowPositionals: false
1339
- });
1340
- if (values.help)
1341
- return "help";
1342
- const intervalMs = Number(values.interval);
1343
- if (!Number.isInteger(intervalMs) || intervalMs < 100)
1344
- throw new UsageError("--interval must be a whole number of ms, at least 100");
1345
- if (values.sim)
1346
- return { source: simulatedSource(), intervalMs, theme: values.theme, transparent: values.transparent };
1347
- let url = values.url;
1348
- if (!url) {
1349
- const port = Number(values.port);
1350
- if (!Number.isInteger(port) || port < 1 || port > 65535)
1351
- throw new UsageError("--port must be between 1 and 65535");
1352
- const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
1353
- url = `http://${host}:${port}/api/game/metrics`;
1354
- }
1355
- try {
1356
- void new URL(url);
1357
- } catch {
1358
- throw new UsageError(`Invalid --url: ${url}`);
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;
1359
1837
  }
1360
- const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN;
1361
- return { source: httpSource({ url, token }), clientUrl: new URL("/", url).href, intervalMs, theme: values.theme, transparent: values.transparent };
1362
- }
1363
- async function runDashboard(options, signal) {
1364
- const { createApp } = await import("@profullstack/hqtui");
1365
- const client = new MetricsClient(options.source, {
1366
- intervalMs: options.intervalMs,
1367
- startupIntervalMs: options.startup ? STARTUP_POLL_MS : void 0
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
+ }
1368
1847
  });
1369
- const view = { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true };
1370
- const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent });
1371
- app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, {
1372
- transparent: options.transparent,
1373
- startup: options.startup?.(),
1374
- openKey: !!options.clientUrl
1375
- }));
1376
- client.onChange(() => app.invalidate());
1377
- const { clientUrl } = options;
1378
- if (clientUrl && options.openBrowser) {
1379
- const off = client.onChange(async (state) => {
1380
- if (state.status !== "live")
1381
- return;
1382
- off();
1383
- if (await servesPage(clientUrl))
1384
- openBrowser(clientUrl);
1385
- });
1848
+ if (view.selectedPacketId === null) {
1849
+ packetsPanel(ui, theme, state, view, transparent);
1850
+ return;
1386
1851
  }
1387
- app.on("key", (event) => {
1388
- if (event.key === "r")
1389
- void client.refresh();
1390
- if (event.key === "o" && options.clientUrl)
1391
- openBrowser(options.clientUrl);
1392
- const tab = tabForKey(event.key);
1393
- if (tab !== null) {
1394
- view.tab = tab;
1395
- app.invalidate();
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);
1396
1860
  }
1397
- if (handlePlayersKey(view, client.state.snapshot, event.key))
1398
- app.invalidate();
1399
1861
  });
1400
- app.on("exit", () => client.stop());
1401
- const onAbort = () => app.stop();
1402
- signal?.addEventListener("abort", onAbort);
1403
- client.start();
1404
- if (!signal?.aborted)
1405
- await app.start();
1406
- signal?.removeEventListener("abort", onAbort);
1407
- client.stop();
1408
1862
  }
1409
-
1410
- // src/commands/dev.ts
1411
- import { execFileSync, spawn as spawn2 } from "child_process";
1412
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1413
- import { constants } from "os";
1414
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
1415
- import process3 from "process";
1416
- import { setTimeout as delay } from "timers/promises";
1417
- import { parseArgs as parseArgs2 } from "util";
1418
- var DEV_HELP = `Usage: mavonengine dev [options]
1419
-
1420
- Run the dev servers of the MavonEngine project you are in (game server and client)
1421
- and show the dashboard for it. Once the game server is up, the game opens in the
1422
- browser. Quitting the dashboard stops the dev servers.
1423
-
1424
- Runs the project's 'dev:server' script, or its 'dev' script when there is none.
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;
2787
+ }
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
+ });
2799
+ }
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);
2870
+ });
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" });
2901
+ return;
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;
2911
+ }
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
+ });
2934
+ });
2935
+ }
2936
+
2937
+ // src/commands/dev.ts
2938
+ var DEV_HELP = `Usage: mavonengine dev [options]
2939
+
2940
+ Run the dev servers of the MavonEngine project you are in (game server and client)
2941
+ and show the dashboard for it. Once the game server is up, the game opens in the
2942
+ browser. Quitting the dashboard stops the dev servers.
2943
+
2944
+ Runs the project's 'dev:server' script, or its 'dev' script when there is none.
1425
2945
  So 'dev' can be 'mavonengine dev' itself, as in the multiplayer template.
1426
2946
 
1427
2947
  Options:
@@ -1456,8 +2976,8 @@ var DEV_FLAGS = {
1456
2976
  open: { type: "boolean", default: true },
1457
2977
  help: { type: "boolean", short: "h", default: false }
1458
2978
  };
1459
- function parseDevArgs(args, cwd = process3.cwd(), env = process3.env) {
1460
- const { values } = parseArgs2({
2979
+ function parseDevArgs(args, cwd = process6.cwd(), env = process6.env) {
2980
+ const { values } = parseArgs3({
1461
2981
  args,
1462
2982
  options: DEV_FLAGS,
1463
2983
  strict: true,
@@ -1479,14 +2999,14 @@ function toDevOptions(values, cwd, env) {
1479
2999
  dashboard = parseDashboardArgs(dashboardArgs, env);
1480
3000
  dashboard.openBrowser = values.open && canOpenBrowserHere(env);
1481
3001
  }
1482
- return { cwd: resolve2(cwd, values.cwd ?? "."), dashboard };
3002
+ return { cwd: resolve4(cwd, values.cwd ?? "."), dashboard };
1483
3003
  }
1484
3004
  function readPackageJson(dir) {
1485
- const path = join2(dir, "package.json");
1486
- if (!existsSync2(path))
3005
+ const path = join4(dir, "package.json");
3006
+ if (!existsSync4(path))
1487
3007
  return void 0;
1488
3008
  try {
1489
- return JSON.parse(readFileSync2(path, "utf8"));
3009
+ return JSON.parse(readFileSync4(path, "utf8"));
1490
3010
  } catch {
1491
3011
  return void 0;
1492
3012
  }
@@ -1497,11 +3017,11 @@ function devScriptOf(pkg) {
1497
3017
  return DEV_SERVER_SCRIPT;
1498
3018
  return typeof scripts.dev === "string" ? "dev" : void 0;
1499
3019
  }
1500
- function checkDevScript(root, env = process3.env) {
3020
+ function checkDevScript(root, env = process6.env) {
1501
3021
  const pkg = readPackageJson(root);
1502
3022
  const script = devScriptOf(pkg);
1503
3023
  if (!script)
1504
- throw new UsageError(`${join2(root, "package.json")} has no '${DEV_SERVER_SCRIPT}' or 'dev' script`);
3024
+ throw new UsageError(`${join4(root, "package.json")} has no '${DEV_SERVER_SCRIPT}' or 'dev' script`);
1505
3025
  const command = String(pkg?.scripts?.[script] ?? "");
1506
3026
  if (env[DEV_ENV_FLAG] || RUNS_MAVONENGINE_DEV_RE.test(command)) {
1507
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'`);
@@ -1509,20 +3029,20 @@ function checkDevScript(root, env = process3.env) {
1509
3029
  return script;
1510
3030
  }
1511
3031
  function findProjectRoot(start) {
1512
- for (let dir = resolve2(start); ; dir = dirname2(dir)) {
3032
+ for (let dir = resolve4(start); ; dir = dirname3(dir)) {
1513
3033
  const pkg = readPackageJson(dir);
1514
3034
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
1515
3035
  if ("@mavonengine/core" in deps && devScriptOf(pkg))
1516
3036
  return dir;
1517
- if (dirname2(dir) === dir)
3037
+ if (dirname3(dir) === dir)
1518
3038
  return void 0;
1519
3039
  }
1520
3040
  }
1521
3041
  function installedEngineVersion(root) {
1522
- return readPackageJson(join2(root, "node_modules", "@mavonengine", "core"))?.version;
3042
+ return readPackageJson(join4(root, "node_modules", "@mavonengine", "core"))?.version;
1523
3043
  }
1524
3044
  function detectPackageManager(root) {
1525
- return LOCKFILES.find(([file]) => existsSync2(join2(root, file)))?.[1] ?? "npm";
3045
+ return LOCKFILES.find(([file]) => existsSync4(join4(root, file)))?.[1] ?? "npm";
1526
3046
  }
1527
3047
  function descendants(root, psOutput) {
1528
3048
  const children = /* @__PURE__ */ new Map();
@@ -1543,7 +3063,7 @@ function descendants(root, psOutput) {
1543
3063
  return found;
1544
3064
  }
1545
3065
  function processTree(pid) {
1546
- if (process3.platform === "win32")
3066
+ if (process6.platform === "win32")
1547
3067
  return [];
1548
3068
  try {
1549
3069
  return [pid, ...descendants(pid, execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }))];
@@ -1554,14 +3074,14 @@ function processTree(pid) {
1554
3074
  function signalAll(pids, signal) {
1555
3075
  for (const p of pids) {
1556
3076
  try {
1557
- process3.kill(p, signal);
3077
+ process6.kill(p, signal);
1558
3078
  } catch {
1559
3079
  }
1560
3080
  }
1561
3081
  }
1562
3082
  function isAlive(pid) {
1563
3083
  try {
1564
- process3.kill(pid, 0);
3084
+ process6.kill(pid, 0);
1565
3085
  return true;
1566
3086
  } catch {
1567
3087
  return false;
@@ -1573,7 +3093,7 @@ async function waitForExit(pids, timeoutMs) {
1573
3093
  await delay(50);
1574
3094
  }
1575
3095
  function killTree(pid, signal) {
1576
- if (process3.platform === "win32") {
3096
+ if (process6.platform === "win32") {
1577
3097
  try {
1578
3098
  execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
1579
3099
  } catch {
@@ -1582,15 +3102,15 @@ function killTree(pid, signal) {
1582
3102
  }
1583
3103
  signalAll(processTree(pid), signal);
1584
3104
  }
1585
- function startDev(root, { capture = false } = {}) {
3105
+ function startDev(root, { capture = false, env = {} } = {}) {
1586
3106
  const pm = detectPackageManager(root);
1587
3107
  const script = checkDevScript(root);
1588
- const child = spawn2(pm, ["run", script], {
3108
+ const child = spawn3(pm, ["run", script], {
1589
3109
  cwd: root,
1590
- env: { ...process3.env, [DEV_ENV_FLAG]: "1" },
3110
+ env: { ...process6.env, ...env, [DEV_ENV_FLAG]: "1" },
1591
3111
  stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
1592
3112
  // npm, pnpm and yarn are .cmd shims on Windows
1593
- shell: process3.platform === "win32"
3113
+ shell: process6.platform === "win32"
1594
3114
  });
1595
3115
  let output = "";
1596
3116
  const keep = (chunk) => {
@@ -1601,7 +3121,7 @@ function startDev(root, { capture = false } = {}) {
1601
3121
  if (capture)
1602
3122
  keep(announce);
1603
3123
  else
1604
- process3.stdout.write(announce);
3124
+ process6.stdout.write(announce);
1605
3125
  child.stdout?.setEncoding("utf8").on("data", keep);
1606
3126
  child.stderr?.setEncoding("utf8").on("data", keep);
1607
3127
  let running = true;
@@ -1609,17 +3129,17 @@ function startDev(root, { capture = false } = {}) {
1609
3129
  };
1610
3130
  const forward = (signal) => child.pid && killTree(child.pid, signal);
1611
3131
  const onExit = () => running && child.pid && killTree(child.pid, "SIGTERM");
1612
- process3.on("SIGINT", onInterrupt);
1613
- process3.on("SIGTERM", forward);
1614
- process3.on("SIGHUP", forward);
1615
- process3.on("exit", onExit);
3132
+ process6.on("SIGINT", onInterrupt);
3133
+ process6.on("SIGTERM", forward);
3134
+ process6.on("SIGHUP", forward);
3135
+ process6.on("exit", onExit);
1616
3136
  const exited = new Promise((resolvePromise, reject) => {
1617
3137
  const cleanup = () => {
1618
3138
  running = false;
1619
- process3.off("SIGINT", onInterrupt);
1620
- process3.off("SIGTERM", forward);
1621
- process3.off("SIGHUP", forward);
1622
- process3.off("exit", onExit);
3139
+ process6.off("SIGINT", onInterrupt);
3140
+ process6.off("SIGTERM", forward);
3141
+ process6.off("SIGHUP", forward);
3142
+ process6.off("exit", onExit);
1623
3143
  };
1624
3144
  child.on("error", (err) => {
1625
3145
  cleanup();
@@ -1632,7 +3152,7 @@ function startDev(root, { capture = false } = {}) {
1632
3152
  });
1633
3153
  const stop = async () => {
1634
3154
  if (running && child.pid) {
1635
- if (process3.platform === "win32") {
3155
+ if (process6.platform === "win32") {
1636
3156
  killTree(child.pid, "SIGTERM");
1637
3157
  } else {
1638
3158
  const tree = processTree(child.pid);
@@ -1646,28 +3166,83 @@ function startDev(root, { capture = false } = {}) {
1646
3166
  };
1647
3167
  return { exited, stop, output: () => output };
1648
3168
  }
1649
- function runDev(root) {
1650
- 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
+ }
1651
3224
  }
1652
3225
  async function runDevWithDashboard(root, dashboard) {
1653
- const dev = startDev(root, { capture: true });
3226
+ const { session: dev, installer, close } = await startDevWithInstaller(root, { capture: true });
1654
3227
  const engineVersion = installedEngineVersion(root);
1655
3228
  const startup = () => ({ engineVersion, output: dev.output() });
1656
3229
  const devExited = new AbortController();
1657
3230
  dev.exited.then(() => devExited.abort(), () => devExited.abort());
1658
3231
  let quit = false;
1659
3232
  try {
1660
- await runDashboard({ ...dashboard, startup }, devExited.signal);
3233
+ await runDashboard({ ...dashboard, startup, installer }, devExited.signal);
1661
3234
  } finally {
1662
3235
  quit = !devExited.signal.aborted;
3236
+ await installer.finished;
1663
3237
  if (quit)
1664
3238
  await dev.stop().catch(() => {
1665
3239
  });
3240
+ await close();
1666
3241
  }
1667
3242
  if (quit)
1668
3243
  return 0;
1669
3244
  const exitCode = await dev.exited;
1670
- process3.stderr.write(`${dev.output()}
3245
+ process6.stderr.write(`${dev.output()}
1671
3246
  mavonengine: the dev script exited with code ${exitCode}
1672
3247
  `);
1673
3248
  return exitCode;
@@ -1677,7 +3252,7 @@ async function runDevCommand(options) {
1677
3252
  if (!root)
1678
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)`);
1679
3254
  checkDevScript(root);
1680
- if (options.dashboard && process3.stdout.isTTY && process3.stdin.isTTY)
3255
+ if (options.dashboard && process6.stdout.isTTY && process6.stdin.isTTY)
1681
3256
  return runDevWithDashboard(root, options.dashboard);
1682
3257
  return runDev(root);
1683
3258
  }
@@ -1693,8 +3268,8 @@ Options:
1693
3268
  --cwd <dir> Start looking for the project here (default: current directory)
1694
3269
  -h, --help Show this help
1695
3270
  `;
1696
- function parseBuildArgs(args, cwd = process4.cwd()) {
1697
- const { values } = parseArgs3({
3271
+ function parseBuildArgs(args, cwd = process7.cwd()) {
3272
+ const { values } = parseArgs4({
1698
3273
  args,
1699
3274
  options: {
1700
3275
  cwd: { type: "string" },
@@ -1705,7 +3280,7 @@ function parseBuildArgs(args, cwd = process4.cwd()) {
1705
3280
  });
1706
3281
  if (values.help)
1707
3282
  return "help";
1708
- return { cwd: resolve3(cwd, values.cwd ?? ".") };
3283
+ return { cwd: resolve5(cwd, values.cwd ?? ".") };
1709
3284
  }
1710
3285
  function summarizeReleaseAssets(root, assets, warnings) {
1711
3286
  const plugins = readConfig(root).plugins;
@@ -1723,172 +3298,44 @@ Plugins in ${CONFIG_FILE}:`];
1723
3298
  lines.push(` warning: ${warning}`);
1724
3299
  return `${lines.join("\n")}
1725
3300
  `;
1726
- }
1727
- function run(command, args, cwd) {
1728
- return new Promise((resolvePromise, reject) => {
1729
- const child = spawn3(command, args, { cwd, stdio: "inherit", shell: process4.platform === "win32" });
1730
- child.on("error", (err) => {
1731
- reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
1732
- });
1733
- child.on("exit", (code) => resolvePromise(code ?? 1));
1734
- });
1735
- }
1736
- async function runBuildCommand(options) {
1737
- const root = findProjectRoot(options.cwd);
1738
- if (!root)
1739
- 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)`);
1740
- const pkg = JSON.parse(readFileSync3(join3(root, "package.json"), "utf8"));
1741
- if (typeof pkg.scripts?.build !== "string")
1742
- throw new UsageError(`${join3(root, "package.json")} has no build script`);
1743
- const pm = detectPackageManager(root);
1744
- process4.stdout.write(`mavonengine: running '${pm} run build' in ${root}
1745
- `);
1746
- const code = await run(pm, ["run", "build"], root);
1747
- if (code !== 0) {
1748
- process4.stderr.write(`mavonengine: the build script exited with code ${code}
1749
- `);
1750
- return code;
1751
- }
1752
- if (existsSync3(join3(root, CONFIG_FILE))) {
1753
- const { assets, warnings } = releaseAssets(root);
1754
- process4.stdout.write(summarizeReleaseAssets(root, assets, warnings));
1755
- }
1756
- return 0;
1757
- }
1758
-
1759
- // src/commands/create.ts
1760
- import { existsSync as existsSync5 } from "fs";
1761
- import process6 from "process";
1762
- import { createInterface } from "readline/promises";
1763
- import { parseArgs as parseArgs4 } from "util";
1764
-
1765
- // src/create/project.ts
1766
- import { spawn as spawn4 } from "child_process";
1767
- import { createWriteStream, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "fs";
1768
- import { tmpdir } from "os";
1769
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve4 } from "path";
1770
- import process5 from "process";
1771
- import { Readable, Transform } from "stream";
1772
- import { pipeline } from "stream/promises";
1773
- import AdmZip from "adm-zip";
1774
- var TEMPLATE_URL = "https://github.com/MavonEngine/Core/archive/refs/tags/0.0.16-alpha.zip";
1775
- var TEMPLATE_SUBPATH = "packages/multiplayer-template";
1776
- var PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/;
1777
- function projectDirFor(projectName, parentDir) {
1778
- return resolve4(parentDir, projectName);
1779
- }
1780
- async function downloadZip(url, { report, signal, fetch = globalThis.fetch }) {
1781
- const response = await fetch(url, { signal });
1782
- if (!response.ok || !response.body)
1783
- throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
1784
- const total = Number(response.headers.get("content-length")) || 0;
1785
- let received = 0;
1786
- const progress = new Transform({
1787
- transform(chunk, _encoding, callback) {
1788
- received += chunk.length;
1789
- report.step(`Downloading the template\u2026 ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ""}`);
1790
- callback(null, chunk);
1791
- }
1792
- });
1793
- const dest = join4(tmpdir(), `mavonengine-create-${process5.pid}-${Date.now()}.zip`);
1794
- try {
1795
- await pipeline(Readable.fromWeb(response.body), progress, createWriteStream(dest), { signal });
1796
- } catch (err) {
1797
- rmSync(dest, { force: true });
1798
- throw err;
1799
- }
1800
- return dest;
1801
- }
1802
- function extractTemplate(zipPath, templateSubpath, destDir) {
1803
- const entries = new AdmZip(zipPath).getEntries();
1804
- if (entries.length === 0)
1805
- throw new Error("Downloaded zip archive is empty");
1806
- const rootFolder = entries[0].entryName.split("/")[0];
1807
- const templatePrefix = `${rootFolder}/${templateSubpath}/`;
1808
- const templateEntries = entries.filter((e) => e.entryName.startsWith(templatePrefix));
1809
- if (templateEntries.length === 0)
1810
- throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`);
1811
- const root = resolve4(destDir);
1812
- let files = 0;
1813
- for (const entry of templateEntries) {
1814
- const relativePath = entry.entryName.slice(templatePrefix.length);
1815
- if (!relativePath)
1816
- continue;
1817
- const outputPath = resolve4(root, relativePath);
1818
- const inside = relative2(root, outputPath);
1819
- if (inside.startsWith("..") || isAbsolute2(inside))
1820
- throw new Error(`Refusing to extract ${entry.entryName} outside the project`);
1821
- if (entry.isDirectory) {
1822
- mkdirSync(outputPath, { recursive: true });
1823
- } else {
1824
- mkdirSync(dirname3(outputPath), { recursive: true });
1825
- writeFileSync2(outputPath, entry.getData());
1826
- files++;
1827
- }
1828
- }
1829
- return files;
1830
- }
1831
- function runCommand(command, args, cwd, { report, signal }) {
1832
- return new Promise((resolvePromise, reject) => {
1833
- const child = spawn4(command, args, {
1834
- cwd,
1835
- stdio: ["ignore", "pipe", "pipe"],
1836
- // npm is a .cmd shim on Windows
1837
- shell: process5.platform === "win32",
1838
- signal
1839
- });
1840
- child.stdout.setEncoding("utf8").on("data", report.output);
1841
- child.stderr.setEncoding("utf8").on("data", report.output);
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" });
1842
3305
  child.on("error", (err) => {
1843
3306
  reject(err.code === "ENOENT" ? new Error(`'${command}' was not found on your PATH`) : err);
1844
3307
  });
1845
- child.on("exit", (code, exitSignal) => {
1846
- if (code === 0)
1847
- resolvePromise();
1848
- else
1849
- reject(new Error(`'${command} ${args.join(" ")}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`));
1850
- });
3308
+ child.on("exit", (code) => resolvePromise(code ?? 1));
1851
3309
  });
1852
3310
  }
1853
- async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }) {
1854
- const projectDir = projectDirFor(projectName, parentDir);
1855
- if (existsSync4(projectDir))
1856
- throw new Error(`Directory "${projectDir}" already exists`);
1857
- report.output(`Creating project "${projectName}" in ${projectDir}
1858
- `);
1859
- mkdirSync(projectDir, { recursive: true });
1860
- report.step("Downloading the template\u2026");
1861
- report.output(`Downloading the template from ${templateUrl}
1862
- `);
1863
- const zipPath = await downloadZip(templateUrl, { report, signal, fetch });
1864
- try {
1865
- report.step("Extracting the template\u2026");
1866
- const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir);
1867
- report.output(`Extracted ${files} files
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}
1868
3320
  `);
1869
- } finally {
1870
- rmSync(zipPath, { force: true });
1871
- }
1872
- const pkgJsonPath = join4(projectDir, "package.json");
1873
- if (existsSync4(pkgJsonPath)) {
1874
- const pkg = JSON.parse(readFileSync4(pkgJsonPath, "utf-8"));
1875
- pkg.name = projectName;
1876
- writeFileSync2(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}
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}
1877
3324
  `);
3325
+ return code;
1878
3326
  }
1879
- if (writeMcpConfig(projectDir))
1880
- report.output(`Wrote ${MCP_CONFIG_FILE} so AI assistants with MCP support can inspect the running game server
1881
- `);
1882
- if (install) {
1883
- signal?.throwIfAborted();
1884
- report.step("Installing dependencies\u2026");
1885
- report.output("Running 'npm install'\n");
1886
- await runCommand("npm", ["install"], projectDir, { report, signal });
3327
+ if (existsSync5(join5(root, CONFIG_FILE))) {
3328
+ const { assets, warnings } = releaseAssets(root);
3329
+ process7.stdout.write(summarizeReleaseAssets(root, assets, warnings));
1887
3330
  }
1888
- return projectDir;
3331
+ return 0;
1889
3332
  }
1890
3333
 
1891
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";
1892
3339
  var CREATE_HELP = `Usage: mavonengine create [name] [options]
1893
3340
 
1894
3341
  Create a MavonEngine project from the multiplayer template, install its
@@ -1904,8 +3351,8 @@ Options:
1904
3351
  -h, --help Show this help
1905
3352
  `;
1906
3353
  var MAX_SETUP_OUTPUT = 64 * 1024;
1907
- function parseCreateArgs(args, cwd = process6.cwd(), env = process6.env) {
1908
- const { values, positionals } = parseArgs4({
3354
+ function parseCreateArgs(args, cwd = process8.cwd(), env = process8.env) {
3355
+ const { values, positionals } = parseArgs5({
1909
3356
  args,
1910
3357
  options: DEV_FLAGS,
1911
3358
  strict: true,
@@ -1926,7 +3373,7 @@ function checkProjectName(name) {
1926
3373
  throw new UsageError(`invalid project name '${name}'`);
1927
3374
  }
1928
3375
  async function promptProjectName() {
1929
- const rl = createInterface({ input: process6.stdin, output: process6.stdout });
3376
+ const rl = createInterface2({ input: process8.stdin, output: process8.stdout });
1930
3377
  try {
1931
3378
  return (await rl.question("Project name: ")).trim();
1932
3379
  } finally {
@@ -1980,18 +3427,18 @@ async function runCreateWithDashboard(projectName, parentDir, dashboard) {
1980
3427
  return 0;
1981
3428
  await work.catch(() => {
1982
3429
  });
1983
- process6.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)}
1984
3431
  `);
1985
3432
  return 130;
1986
3433
  }
1987
3434
  try {
1988
3435
  const exitCode = await work;
1989
- process6.stderr.write(`${dev.output()}
3436
+ process8.stderr.write(`${dev.output()}
1990
3437
  mavonengine: the dev script exited with code ${exitCode}
1991
3438
  `);
1992
3439
  return exitCode;
1993
3440
  } catch (err) {
1994
- process6.stderr.write(output);
3441
+ process8.stderr.write(output);
1995
3442
  throw err;
1996
3443
  }
1997
3444
  }
@@ -1999,16 +3446,16 @@ async function runCreatePlain(projectName, parentDir) {
1999
3446
  const report = {
2000
3447
  step: () => {
2001
3448
  },
2002
- output: (chunk) => process6.stdout.write(chunk)
3449
+ output: (chunk) => process8.stdout.write(chunk)
2003
3450
  };
2004
3451
  const dir = await createProject({ projectName, parentDir, report });
2005
- process6.stdout.write(`
3452
+ process8.stdout.write(`
2006
3453
  mavonengine: your project is ready at ${dir}
2007
3454
  `);
2008
3455
  return runDev(dir);
2009
3456
  }
2010
3457
  async function runCreateCommand(options) {
2011
- const interactive = !!process6.stdout.isTTY && !!process6.stdin.isTTY;
3458
+ const interactive = !!process8.stdout.isTTY && !!process8.stdin.isTTY;
2012
3459
  let projectName = options.projectName;
2013
3460
  if (!projectName) {
2014
3461
  if (!interactive)
@@ -2019,191 +3466,24 @@ async function runCreateCommand(options) {
2019
3466
  checkProjectName(projectName);
2020
3467
  }
2021
3468
  const dir = projectDirFor(projectName, options.cwd);
2022
- if (existsSync5(dir))
3469
+ if (existsSync6(dir))
2023
3470
  throw new UsageError(`${dir} already exists`);
2024
3471
  if (options.dashboard && interactive)
2025
3472
  return runCreateWithDashboard(projectName, options.cwd, options.dashboard);
2026
3473
  return runCreatePlain(projectName, options.cwd);
2027
3474
  }
2028
3475
 
2029
- // src/commands/install.ts
2030
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2031
- import { join as join5, resolve as resolve5 } from "path";
2032
- import process7 from "process";
2033
- import { createInterface as createInterface2 } from "readline/promises";
2034
- import { parseArgs as parseArgs5 } from "util";
2035
- var INSTALL_HELP = `Usage: mavonengine install <package...> [options]
2036
-
2037
- Install packages into the MavonEngine project you are in. For each one that is a
2038
- MavonEngine plugin, asks whether to add it to the plugins in ${CONFIG_FILE}, so it
2039
- loads on the client and/or the game server and is built into the release.
2040
-
2041
- Takes anything your package manager does: names, name@version, file: paths, git URLs.
2042
-
2043
- Options:
2044
- --cwd <dir> Start looking for the project here (default: current directory)
2045
- -y, --yes Add plugins to ${CONFIG_FILE} without asking
2046
- --no-register Only install, leave ${CONFIG_FILE} alone
2047
- -h, --help Show this help
2048
- `;
2049
- var PACKAGE_SPEC_RE = /^((?:@[\w.-]+\/)?[\w.-]+)(?:@.*)?$/;
2050
- var LOCAL_PATH_RE = /^(?:\.{1,2}(?:[/\\]|$)|[/~]|[a-z]:[/\\])/i;
2051
- var TARGET_LABELS = {
2052
- client: "client only",
2053
- server: "server only",
2054
- both: "client + server"
2055
- };
2056
- function parseInstallArgs(args, cwd = process7.cwd()) {
2057
- const { values, positionals } = parseArgs5({
2058
- args,
2059
- options: {
2060
- cwd: { type: "string" },
2061
- yes: { type: "boolean", short: "y", default: false },
2062
- register: { type: "boolean", default: true },
2063
- help: { type: "boolean", short: "h", default: false }
2064
- },
2065
- strict: true,
2066
- allowPositionals: true,
2067
- allowNegative: true
2068
- });
2069
- if (values.help)
2070
- return "help";
2071
- if (positionals.length === 0)
2072
- throw new UsageError(`which package? e.g. mavonengine install my-plugin
2073
-
2074
- ${INSTALL_HELP}`);
2075
- return { cwd: resolve5(cwd, values.cwd ?? "."), packages: positionals, yes: values.yes, register: values.register };
2076
- }
2077
- function packageNameFromSpec(spec, root) {
2078
- const localPath = spec.startsWith("file:") ? spec.slice("file:".length) : LOCAL_PATH_RE.test(spec) ? spec : void 0;
2079
- if (localPath !== void 0) {
2080
- const packageJson = join5(resolve5(root, localPath), "package.json");
2081
- try {
2082
- return existsSync6(packageJson) ? JSON.parse(readFileSync5(packageJson, "utf8")).name : void 0;
2083
- } catch {
2084
- return void 0;
2085
- }
2086
- }
2087
- if (spec.includes(":") || spec.endsWith(".tgz"))
2088
- return void 0;
2089
- return PACKAGE_SPEC_RE.exec(spec)?.[1];
2090
- }
2091
- function installArgs(pm, packages) {
2092
- return [pm === "npm" ? "install" : "add", ...packages];
2093
- }
2094
- function dependencyNames(root) {
2095
- const path = join5(root, "package.json");
2096
- if (!existsSync6(path))
2097
- return /* @__PURE__ */ new Map();
2098
- const pkg = JSON.parse(readFileSync5(path, "utf8"));
2099
- return new Map(Object.entries({ ...pkg.devDependencies, ...pkg.dependencies }));
2100
- }
2101
- function installedNames(specs, root, before, after) {
2102
- const names = specs.map((spec) => packageNameFromSpec(spec, root)).filter((name) => !!name);
2103
- for (const [name, version2] of after) {
2104
- if (before.get(name) !== version2)
2105
- names.push(name);
2106
- }
2107
- return [...new Set(names)];
2108
- }
2109
- function describePlugin(plugin, engineVersion) {
2110
- const { manifest } = plugin;
2111
- const lines = [`${plugin.package}${plugin.version ? `@${plugin.version}` : ""} is a MavonEngine plugin (${TARGET_LABELS[manifest.target]})`];
2112
- const prefabs = Object.keys(manifest.assets?.prefabs ?? {});
2113
- if (prefabs.length)
2114
- lines.push(` Prefabs: ${prefabs.join(", ")}. Their assets ship in the release once you place them in the editor`);
2115
- if (manifest.engine)
2116
- lines.push(` Made for @mavonengine/core ${manifest.engine}${engineVersion ? `, you have ${engineVersion}` : ""}`);
2117
- return `${lines.join("\n")}
2118
- `;
2119
- }
2120
- function confirmOnTerminal(question) {
2121
- const rl = createInterface2({ input: process7.stdin, output: process7.stdout });
2122
- return rl.question(question).then((answer) => ["", "y", "yes"].includes(answer.trim().toLowerCase())).finally(() => rl.close());
2123
- }
2124
- var terminalIO = {
2125
- run: (command, args, cwd) => runCommand(command, args, cwd, {
2126
- report: { step: () => {
2127
- }, output: (chunk) => process7.stdout.write(chunk) }
2128
- }),
2129
- confirm: confirmOnTerminal,
2130
- get interactive() {
2131
- return !!process7.stdin.isTTY && !!process7.stdout.isTTY;
2132
- },
2133
- write: (text) => process7.stdout.write(text)
2134
- };
2135
- async function runInstallCommand(options, io = terminalIO) {
2136
- const root = findProjectRoot(options.cwd);
2137
- if (!root)
2138
- 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)`);
2139
- const config = readConfig(root);
2140
- const pm = detectPackageManager(root);
2141
- const args = installArgs(pm, options.packages);
2142
- const before = dependencyNames(root);
2143
- io.write(`mavonengine: running '${pm} ${args.join(" ")}' in ${root}
2144
- `);
2145
- await io.run(pm, args, root);
2146
- const engineVersion = installedEngineVersion(root);
2147
- let failed = false;
2148
- const names = installedNames(options.packages, root, before, dependencyNames(root));
2149
- if (names.length === 0)
2150
- io.write(`
2151
- mavonengine: couldn't tell which packages were installed. If one is a plugin, run 'mavonengine install <its name>' to register it
2152
- `);
2153
- for (const name of names) {
2154
- let plugin;
2155
- try {
2156
- plugin = readPluginManifest(root, name);
2157
- } catch (err) {
2158
- io.write(`
2159
- mavonengine: ${err.message}
2160
- `);
2161
- failed = true;
2162
- continue;
2163
- }
2164
- if (!plugin) {
2165
- io.write(`
2166
- ${name} is installed. It is not a MavonEngine plugin (no "mavonengine" field in its package.json), so ${CONFIG_FILE} is left alone
2167
- `);
2168
- continue;
2169
- }
2170
- io.write(`
2171
- ${describePlugin(plugin, engineVersion)}`);
2172
- const entry = config.plugins[name];
2173
- if (entry && entry.enabled !== false) {
2174
- io.write(` Already in ${CONFIG_FILE}
2175
- `);
2176
- continue;
2177
- }
2178
- if (!options.register)
2179
- continue;
2180
- if (!options.yes && !io.interactive) {
2181
- io.write(` Not added to ${CONFIG_FILE}: run with --yes, or add "${name}": { "enabled": true } to its "plugins"
2182
- `);
2183
- continue;
2184
- }
2185
- if (!options.yes && !await io.confirm(`Add ${name} to the plugins in ${CONFIG_FILE}? (Y/n) `)) {
2186
- io.write(` Not added. Add "${name}": { "enabled": true } to "plugins" in ${CONFIG_FILE} to use it later
2187
- `);
2188
- continue;
2189
- }
2190
- addPluginToConfig(root, name);
2191
- io.write(` Added to ${join5(root, CONFIG_FILE)}
2192
- `);
2193
- if (plugin.manifest.target !== "client")
2194
- io.write(` Restart the game server ('mavonengine dev') to load it there
2195
- `);
2196
- }
2197
- return failed ? 1 : 0;
2198
- }
2199
-
2200
3476
  // src/commands/mcp.ts
2201
- import process8 from "process";
3477
+ import process9 from "process";
2202
3478
  import { parseArgs as parseArgs6 } from "util";
2203
3479
 
2204
3480
  // src/mcp/GameServerClient.ts
2205
3481
  var GameServerError = class extends Error {
2206
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
+ }
2207
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";
2208
3488
  var GameServerClient = class {
2209
3489
  url;
@@ -2252,6 +3532,26 @@ var GameServerClient = class {
2252
3532
  entity(id) {
2253
3533
  return this.get(`api/game/dev/entities/${encodeURIComponent(id)}`, {}, DEV_UNAVAILABLE);
2254
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
+ }
2255
3555
  /** The game tabs open in a browser, through the dev bridge */
2256
3556
  clients() {
2257
3557
  return this.bridge("__mavonengine/clients", {});
@@ -2321,9 +3621,10 @@ var MCP_HELP = `Usage: mavonengine mcp [options]
2321
3621
  Model Context Protocol (MCP) server over stdio, for any MCP client and model
2322
3622
  (Claude Code, Cursor, VS Code, Codex, Gemini CLI, Zed, local models, \u2026).
2323
3623
  It lets the assistant read a running game: the game server (status, metrics,
2324
- players, scene, entities, logs, HTTP requests) and the game tabs open in a
2325
- browser (renderer stats, entities, network, console, screenshots), and compare
2326
- an entity on both sides. Every tool is read-only. Server scene and entity tools
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
2327
3628
  need the game server in development (NODE_ENV is not "production"); client
2328
3629
  tools need the client served by Vite in dev.
2329
3630
 
@@ -2339,13 +3640,18 @@ Options:
2339
3640
  --url <url> Server base URL, e.g. http://localhost:8050, overrides --host and --port
2340
3641
  --client-url <url> Where Vite serves the client in dev (default: the server URL,
2341
3642
  which serves it in the template), e.g. http://localhost:5173
2342
- --token <token> Metrics token (or set MAVONENGINE_METRICS_TOKEN)
3643
+ --token <token> Metrics or admin token (or set MAVONENGINE_METRICS_TOKEN
3644
+ or MAVONENGINE_ADMIN_TOKEN)
2343
3645
  --docs-url <url> MCP server of the documentation for the docs_* tools
2344
3646
  (default: https://mavonengine.com/mcp)
2345
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
2346
3652
  -h, --help Show this help
2347
3653
  `;
2348
- function parseMcpArgs(args, env = process8.env) {
3654
+ function parseMcpArgs(args, env = process9.env) {
2349
3655
  const { values } = parseArgs6({
2350
3656
  args,
2351
3657
  options: {
@@ -2356,6 +3662,8 @@ function parseMcpArgs(args, env = process8.env) {
2356
3662
  "token": { type: "string" },
2357
3663
  "docs-url": { type: "string" },
2358
3664
  "docs": { type: "boolean", default: true },
3665
+ "plugins-url": { type: "string" },
3666
+ "plugin-list": { type: "boolean", default: true },
2359
3667
  "help": { type: "boolean", short: "h", default: false }
2360
3668
  },
2361
3669
  strict: true,
@@ -2372,7 +3680,7 @@ function parseMcpArgs(args, env = process8.env) {
2372
3680
  const host = values.host.includes(":") && !values.host.startsWith("[") ? `[${values.host}]` : values.host;
2373
3681
  url = `http://${host}:${port}`;
2374
3682
  }
2375
- for (const [flag, value] of [["--url", url], ["--client-url", values["client-url"]], ["--docs-url", values["docs-url"]]]) {
3683
+ for (const [flag, value] of [["--url", url], ["--client-url", values["client-url"]], ["--docs-url", values["docs-url"]], ["--plugins-url", values["plugins-url"]]]) {
2376
3684
  try {
2377
3685
  if (value !== void 0)
2378
3686
  void new URL(value);
@@ -2381,25 +3689,26 @@ function parseMcpArgs(args, env = process8.env) {
2381
3689
  }
2382
3690
  }
2383
3691
  return {
2384
- client: new GameServerClient({ url, clientUrl: values["client-url"], token: values.token ?? env.MAVONENGINE_METRICS_TOKEN }),
2385
- docsUrl: values.docs ? values["docs-url"] ?? DEFAULT_DOCS_URL : false
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
2386
3695
  };
2387
3696
  }
2388
- async function runMcpCommand({ client, docsUrl }) {
3697
+ async function runMcpCommand({ client, docsUrl, pluginsUrl }) {
2389
3698
  const [{ StdioServerTransport }, { createMcpServer }, { DocsClient }] = await Promise.all([
2390
3699
  import("@modelcontextprotocol/sdk/server/stdio.js"),
2391
- import("./server-35RY6IGC.js"),
2392
- import("./docs-4C7X6FUQ.js")
3700
+ import("./server-NNL4IMYV.js"),
3701
+ import("./docs-O2V2WSUG.js")
2393
3702
  ]);
2394
3703
  const docs = docsUrl ? new DocsClient(docsUrl) : void 0;
2395
- const server = createMcpServer(client, docs);
3704
+ const server = createMcpServer(client, docs, pluginsUrl ? { url: pluginsUrl } : void 0);
2396
3705
  const transport = new StdioServerTransport();
2397
- const closed = new Promise((resolve6) => {
2398
- transport.onclose = resolve6;
2399
- process8.stdin.on("end", resolve6);
3706
+ const closed = new Promise((resolve7) => {
3707
+ transport.onclose = resolve7;
3708
+ process9.stdin.on("end", resolve7);
2400
3709
  });
2401
3710
  await server.connect(transport);
2402
- process8.stderr.write(`mavonengine mcp: reading the game server at ${client.url}
3711
+ process9.stderr.write(`mavonengine mcp: reading the game server at ${client.url}
2403
3712
  `);
2404
3713
  await closed;
2405
3714
  await server.close().catch(() => {
@@ -2408,6 +3717,98 @@ async function runMcpCommand({ client, docsUrl }) {
2408
3717
  return 0;
2409
3718
  }
2410
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
+
2411
3812
  // src/index.ts
2412
3813
  var HELP = `Usage: mavonengine <command> [options]
2413
3814
 
@@ -2416,6 +3817,7 @@ Commands:
2416
3817
  dashboard Live terminal dashboard for a running game server
2417
3818
  dev Run the project's dev servers (game server and client)
2418
3819
  install Install packages and register engine plugins in mavonengine.json
3820
+ plugins List the plugins that are available to install
2419
3821
  build Build the project for release, with the plugin assets it uses
2420
3822
  mcp MCP server so AI assistants (any MCP client) can inspect a running game server
2421
3823
 
@@ -2428,18 +3830,18 @@ Run 'mavonengine <command> --help' for command options.
2428
3830
  async function main(argv) {
2429
3831
  const [command, ...args] = argv;
2430
3832
  if (!command || command === "-h" || command === "--help" || command === "help") {
2431
- process9.stdout.write(HELP);
3833
+ process11.stdout.write(HELP);
2432
3834
  return 0;
2433
3835
  }
2434
3836
  if (command === "-v" || command === "--version") {
2435
- process9.stdout.write(`${version}
3837
+ process11.stdout.write(`${version}
2436
3838
  `);
2437
3839
  return 0;
2438
3840
  }
2439
3841
  if (command === "create") {
2440
3842
  const options = parseCreateArgs(args);
2441
3843
  if (options === "help") {
2442
- process9.stdout.write(CREATE_HELP);
3844
+ process11.stdout.write(CREATE_HELP);
2443
3845
  return 0;
2444
3846
  }
2445
3847
  return runCreateCommand(options);
@@ -2447,11 +3849,11 @@ async function main(argv) {
2447
3849
  if (command === "dashboard") {
2448
3850
  const options = parseDashboardArgs(args);
2449
3851
  if (options === "help") {
2450
- process9.stdout.write(DASHBOARD_HELP);
3852
+ process11.stdout.write(DASHBOARD_HELP);
2451
3853
  return 0;
2452
3854
  }
2453
- if (!process9.stdout.isTTY || !process9.stdin.isTTY) {
2454
- process9.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");
2455
3857
  return 1;
2456
3858
  }
2457
3859
  await runDashboard(options);
@@ -2460,7 +3862,7 @@ async function main(argv) {
2460
3862
  if (command === "dev") {
2461
3863
  const options = parseDevArgs(args);
2462
3864
  if (options === "help") {
2463
- process9.stdout.write(DEV_HELP);
3865
+ process11.stdout.write(DEV_HELP);
2464
3866
  return 0;
2465
3867
  }
2466
3868
  return runDevCommand(options);
@@ -2468,15 +3870,23 @@ async function main(argv) {
2468
3870
  if (command === "install" || command === "i" || command === "add") {
2469
3871
  const options = parseInstallArgs(args);
2470
3872
  if (options === "help") {
2471
- process9.stdout.write(INSTALL_HELP);
3873
+ process11.stdout.write(INSTALL_HELP);
2472
3874
  return 0;
2473
3875
  }
2474
3876
  return runInstallCommand(options);
2475
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
+ }
2476
3886
  if (command === "mcp") {
2477
3887
  const options = parseMcpArgs(args);
2478
3888
  if (options === "help") {
2479
- process9.stdout.write(MCP_HELP);
3889
+ process11.stdout.write(MCP_HELP);
2480
3890
  return 0;
2481
3891
  }
2482
3892
  return runMcpCommand(options);
@@ -2484,7 +3894,7 @@ async function main(argv) {
2484
3894
  if (command === "build") {
2485
3895
  const options = parseBuildArgs(args);
2486
3896
  if (options === "help") {
2487
- process9.stdout.write(BUILD_HELP);
3897
+ process11.stdout.write(BUILD_HELP);
2488
3898
  return 0;
2489
3899
  }
2490
3900
  return runBuildCommand(options);
@@ -2493,9 +3903,9 @@ async function main(argv) {
2493
3903
 
2494
3904
  ${HELP}`);
2495
3905
  }
2496
- main(process9.argv.slice(2)).then((code) => process9.exit(code)).catch((err) => {
3906
+ main(process11.argv.slice(2)).then((code) => process11.exit(code)).catch((err) => {
2497
3907
  const isUsage = err instanceof UsageError || err.code?.startsWith("ERR_PARSE_ARGS");
2498
- process9.stderr.write(`mavonengine: ${err.message}
3908
+ process11.stderr.write(`mavonengine: ${err.message}
2499
3909
  `);
2500
- process9.exit(isUsage ? 2 : 1);
3910
+ process11.exit(isUsage ? 2 : 1);
2501
3911
  });