@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/README.md +86 -3
- package/dist/{chunk-OSCNM66O.js → chunk-NIBSLCCZ.js} +1 -1
- package/dist/{chunk-TNN4S5K4.js → chunk-XPKCYSMN.js} +1 -1
- package/dist/chunk-ZEMFSSJP.js +87 -0
- package/dist/{docs-4C7X6FUQ.js → docs-O2V2WSUG.js} +2 -2
- package/dist/index.js +1948 -538
- package/dist/{server-35RY6IGC.js → server-NNL4IMYV.js} +231 -37
- package/package.json +1 -1
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-
|
|
14
|
+
} from "./chunk-XPKCYSMN.js";
|
|
8
15
|
|
|
9
16
|
// src/index.ts
|
|
10
|
-
import
|
|
17
|
+
import process11 from "process";
|
|
11
18
|
|
|
12
19
|
// src/commands/build.ts
|
|
13
|
-
import { spawn as
|
|
14
|
-
import { existsSync as
|
|
15
|
-
import { join as
|
|
16
|
-
import
|
|
17
|
-
import { parseArgs as
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1304
|
-
var
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
view
|
|
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
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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 =
|
|
1460
|
-
const { values } =
|
|
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:
|
|
3002
|
+
return { cwd: resolve4(cwd, values.cwd ?? "."), dashboard };
|
|
1483
3003
|
}
|
|
1484
3004
|
function readPackageJson(dir) {
|
|
1485
|
-
const path =
|
|
1486
|
-
if (!
|
|
3005
|
+
const path = join4(dir, "package.json");
|
|
3006
|
+
if (!existsSync4(path))
|
|
1487
3007
|
return void 0;
|
|
1488
3008
|
try {
|
|
1489
|
-
return JSON.parse(
|
|
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 =
|
|
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(`${
|
|
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 =
|
|
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 (
|
|
3037
|
+
if (dirname3(dir) === dir)
|
|
1518
3038
|
return void 0;
|
|
1519
3039
|
}
|
|
1520
3040
|
}
|
|
1521
3041
|
function installedEngineVersion(root) {
|
|
1522
|
-
return readPackageJson(
|
|
3042
|
+
return readPackageJson(join4(root, "node_modules", "@mavonengine", "core"))?.version;
|
|
1523
3043
|
}
|
|
1524
3044
|
function detectPackageManager(root) {
|
|
1525
|
-
return LOCKFILES.find(([file]) =>
|
|
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 (
|
|
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
|
-
|
|
3077
|
+
process6.kill(p, signal);
|
|
1558
3078
|
} catch {
|
|
1559
3079
|
}
|
|
1560
3080
|
}
|
|
1561
3081
|
}
|
|
1562
3082
|
function isAlive(pid) {
|
|
1563
3083
|
try {
|
|
1564
|
-
|
|
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 (
|
|
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 =
|
|
3108
|
+
const child = spawn3(pm, ["run", script], {
|
|
1589
3109
|
cwd: root,
|
|
1590
|
-
env: { ...
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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 (
|
|
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
|
|
1650
|
-
|
|
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 =
|
|
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
|
-
|
|
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 &&
|
|
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 =
|
|
1697
|
-
const { values } =
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
1854
|
-
const
|
|
1855
|
-
if (
|
|
1856
|
-
throw new
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
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 (
|
|
1880
|
-
|
|
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
|
|
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 =
|
|
1908
|
-
const { values, positionals } =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
3449
|
+
output: (chunk) => process8.stdout.write(chunk)
|
|
2003
3450
|
};
|
|
2004
3451
|
const dir = await createProject({ projectName, parentDir, report });
|
|
2005
|
-
|
|
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 = !!
|
|
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 (
|
|
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
|
|
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
|
|
2325
|
-
browser (renderer stats, entities, network, console, screenshots),
|
|
2326
|
-
an entity on both sides
|
|
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 =
|
|
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-
|
|
2392
|
-
import("./docs-
|
|
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((
|
|
2398
|
-
transport.onclose =
|
|
2399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3833
|
+
process11.stdout.write(HELP);
|
|
2432
3834
|
return 0;
|
|
2433
3835
|
}
|
|
2434
3836
|
if (command === "-v" || command === "--version") {
|
|
2435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3852
|
+
process11.stdout.write(DASHBOARD_HELP);
|
|
2451
3853
|
return 0;
|
|
2452
3854
|
}
|
|
2453
|
-
if (!
|
|
2454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
3908
|
+
process11.stderr.write(`mavonengine: ${err.message}
|
|
2499
3909
|
`);
|
|
2500
|
-
|
|
3910
|
+
process11.exit(isUsage ? 2 : 1);
|
|
2501
3911
|
});
|