dominus-cli 2.2.0 → 2.2.1
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/CHANGELOG.md +18 -0
- package/README.md +19 -11
- package/dist/bridge-daemon.js +581 -0
- package/dist/bridge-daemon.js.map +1 -0
- package/dist/install-plugin.js +497 -27
- package/dist/install-plugin.js.map +1 -1
- package/dist/mcp.js +119 -464
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/plugin/Dominus.rbxmx +7 -3
- package/plugin/default.project.json +4 -1
- package/plugin/src/init.server.lua +5 -1
package/dist/mcp.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs4 from 'fs';
|
|
5
|
+
import path4 from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
7
9
|
import { EventEmitter } from 'events';
|
|
8
|
-
import { WebSocket
|
|
10
|
+
import { WebSocket } from 'ws';
|
|
9
11
|
import { nanoid } from 'nanoid';
|
|
10
12
|
import { z } from 'zod';
|
|
11
13
|
import * as crypto from 'crypto';
|
|
12
|
-
import { randomUUID } from 'crypto';
|
|
13
14
|
|
|
14
|
-
var DOMINUS_DIR =
|
|
15
|
-
var CONFIG_PATH =
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
var DOMINUS_DIR = path4.join(os.homedir(), ".dominus");
|
|
16
|
+
var CONFIG_PATH = path4.join(DOMINUS_DIR, "config.json");
|
|
17
|
+
path4.join(DOMINUS_DIR, "dominus.db");
|
|
18
|
+
path4.join(DOMINUS_DIR, "rules.json");
|
|
18
19
|
var DEFAULTS = {
|
|
19
20
|
provider: "openai",
|
|
20
21
|
apiBaseUrl: "",
|
|
@@ -28,14 +29,14 @@ var DEFAULTS = {
|
|
|
28
29
|
userName: "Developer"
|
|
29
30
|
};
|
|
30
31
|
function ensureDir() {
|
|
31
|
-
if (!
|
|
32
|
-
|
|
32
|
+
if (!fs4.existsSync(DOMINUS_DIR)) {
|
|
33
|
+
fs4.mkdirSync(DOMINUS_DIR, { recursive: true });
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
function readJson(filePath, defaults) {
|
|
36
37
|
try {
|
|
37
|
-
if (
|
|
38
|
-
const raw =
|
|
38
|
+
if (fs4.existsSync(filePath)) {
|
|
39
|
+
const raw = fs4.readFileSync(filePath, "utf-8");
|
|
39
40
|
return { ...defaults, ...JSON.parse(raw) };
|
|
40
41
|
}
|
|
41
42
|
} catch {
|
|
@@ -144,27 +145,21 @@ var wsMessageSchema = z.object({
|
|
|
144
145
|
}).strict();
|
|
145
146
|
var TOKEN_FILE = "bridge-token";
|
|
146
147
|
function loadOrCreateBridgeToken() {
|
|
147
|
-
const tokenPath =
|
|
148
|
+
const tokenPath = path4.join(getDominusDir(), TOKEN_FILE);
|
|
148
149
|
try {
|
|
149
|
-
const existing =
|
|
150
|
+
const existing = fs4.readFileSync(tokenPath, "utf8").trim();
|
|
150
151
|
if (existing.length >= 32) return existing;
|
|
151
152
|
} catch {
|
|
152
153
|
}
|
|
153
154
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
154
|
-
|
|
155
|
+
fs4.writeFileSync(tokenPath, `${token}
|
|
155
156
|
`, { encoding: "utf8", mode: 384 });
|
|
156
157
|
try {
|
|
157
|
-
|
|
158
|
+
fs4.chmodSync(tokenPath, 384);
|
|
158
159
|
} catch {
|
|
159
160
|
}
|
|
160
161
|
return token;
|
|
161
162
|
}
|
|
162
|
-
function tokensEqual(actual, expected) {
|
|
163
|
-
if (typeof actual !== "string") return false;
|
|
164
|
-
const actualBuffer = Buffer.from(actual);
|
|
165
|
-
const expectedBuffer = Buffer.from(expected);
|
|
166
|
-
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
167
|
-
}
|
|
168
163
|
|
|
169
164
|
// src/transport/ws-client.ts
|
|
170
165
|
var DominusClient = class extends EventEmitter {
|
|
@@ -197,10 +192,14 @@ var DominusClient = class extends EventEmitter {
|
|
|
197
192
|
}, this.connectTimeoutMs);
|
|
198
193
|
ws.on("open", () => {
|
|
199
194
|
this.ws = ws;
|
|
200
|
-
ws.send(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
195
|
+
ws.send(
|
|
196
|
+
serializeMessage(
|
|
197
|
+
createMessage(ControllerMsg.HELLO, {
|
|
198
|
+
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
199
|
+
token: this.token
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
);
|
|
204
203
|
});
|
|
205
204
|
ws.on("message", (raw) => {
|
|
206
205
|
try {
|
|
@@ -341,7 +340,11 @@ var DominusClient = class extends EventEmitter {
|
|
|
341
340
|
sendRequest(type, payload, timeoutMs = 15e3) {
|
|
342
341
|
const target = this.resolveTargetInfo();
|
|
343
342
|
if (!target || !this.activeConnectionId) {
|
|
344
|
-
return Promise.reject(
|
|
343
|
+
return Promise.reject(
|
|
344
|
+
new Error(
|
|
345
|
+
this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"
|
|
346
|
+
)
|
|
347
|
+
);
|
|
345
348
|
}
|
|
346
349
|
const request = createMessage(type, payload);
|
|
347
350
|
const relay = createMessage(ControllerMsg.RELAY, {
|
|
@@ -370,10 +373,12 @@ var DominusClient = class extends EventEmitter {
|
|
|
370
373
|
sendNotification(type, payload) {
|
|
371
374
|
if (!this.activeConnectionId) return;
|
|
372
375
|
const request = createMessage(type, payload);
|
|
373
|
-
this.sendMessage(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
376
|
+
this.sendMessage(
|
|
377
|
+
createMessage(ControllerMsg.RELAY, {
|
|
378
|
+
connectionId: this.activeConnectionId,
|
|
379
|
+
request
|
|
380
|
+
})
|
|
381
|
+
);
|
|
377
382
|
}
|
|
378
383
|
isConnected() {
|
|
379
384
|
return this.resolveTargetInfo() !== null;
|
|
@@ -412,9 +417,13 @@ var DominusClient = class extends EventEmitter {
|
|
|
412
417
|
void this.setActiveConnectionId(null);
|
|
413
418
|
return;
|
|
414
419
|
}
|
|
415
|
-
const matches = [...this.connections.values()].filter(
|
|
420
|
+
const matches = [...this.connections.values()].filter(
|
|
421
|
+
(connection) => connection.placeId === placeId
|
|
422
|
+
);
|
|
416
423
|
if (matches.length !== 1) {
|
|
417
|
-
throw new Error(
|
|
424
|
+
throw new Error(
|
|
425
|
+
matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
|
|
426
|
+
);
|
|
418
427
|
}
|
|
419
428
|
void this.setActiveConnectionId(matches[0].connectionId);
|
|
420
429
|
}
|
|
@@ -475,413 +484,74 @@ async function isPortInUse(port) {
|
|
|
475
484
|
});
|
|
476
485
|
});
|
|
477
486
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const wss = new WebSocketServer({
|
|
508
|
-
port,
|
|
509
|
-
host: this.host,
|
|
510
|
-
maxPayload: this.maxPayloadBytes,
|
|
511
|
-
perMessageDeflate: false
|
|
512
|
-
});
|
|
513
|
-
wss.on("listening", () => {
|
|
514
|
-
this.wss = wss;
|
|
515
|
-
this.port = port;
|
|
516
|
-
wss.on("connection", (ws) => this.handleConnection(ws));
|
|
517
|
-
this.emit("server:started", { port, host: this.host });
|
|
518
|
-
resolve();
|
|
519
|
-
});
|
|
520
|
-
wss.on("error", (err) => {
|
|
521
|
-
if (err.code === "EADDRINUSE" && attempt < maxRetries) {
|
|
522
|
-
wss.close();
|
|
523
|
-
tryPort(port + 1, attempt + 1);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
this.emit("server:error", err);
|
|
527
|
-
reject(err);
|
|
528
|
-
});
|
|
529
|
-
};
|
|
530
|
-
tryPort(this.port, 0);
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
handleConnection(ws) {
|
|
534
|
-
this.socketRoles.set(ws, "unknown");
|
|
535
|
-
this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
|
|
536
|
-
const handshakeTimer = setTimeout(() => {
|
|
537
|
-
if (this.socketRoles.get(ws) === "unknown") {
|
|
538
|
-
ws.close(1008, "Dominus handshake timed out");
|
|
539
|
-
}
|
|
540
|
-
}, this.handshakeTimeoutMs);
|
|
541
|
-
ws.on("message", (raw) => {
|
|
542
|
-
try {
|
|
543
|
-
if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
|
|
544
|
-
ws.close(1009, "Dominus message limit exceeded");
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
const msg = parseMessage(raw.toString());
|
|
548
|
-
const role = this.socketRoles.get(ws) ?? "unknown";
|
|
549
|
-
if (role === "unknown") {
|
|
550
|
-
clearTimeout(handshakeTimer);
|
|
551
|
-
this.handleHandshake(ws, msg);
|
|
552
|
-
} else if (role === "controller") {
|
|
553
|
-
this.handleControllerMessage(ws, msg);
|
|
554
|
-
} else if (role === "studio") {
|
|
555
|
-
this.handleStudioMessage(ws, msg);
|
|
556
|
-
}
|
|
557
|
-
} catch (err) {
|
|
558
|
-
this.emit("server:error", err);
|
|
559
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
560
|
-
error: err instanceof Error ? err.message : String(err)
|
|
561
|
-
}));
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
ws.on("close", () => {
|
|
565
|
-
clearTimeout(handshakeTimer);
|
|
566
|
-
this.cleanupSocket(ws);
|
|
567
|
-
});
|
|
568
|
-
ws.on("error", (err) => this.emit("server:error", err));
|
|
569
|
-
}
|
|
570
|
-
consumeRateBudget(ws) {
|
|
571
|
-
const now = Date.now();
|
|
572
|
-
const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
|
|
573
|
-
if (now - state.startedAt >= RATE_WINDOW_MS) {
|
|
574
|
-
state.startedAt = now;
|
|
575
|
-
state.count = 0;
|
|
576
|
-
}
|
|
577
|
-
state.count += 1;
|
|
578
|
-
this.rateState.set(ws, state);
|
|
579
|
-
return state.count <= RATE_LIMIT;
|
|
580
|
-
}
|
|
581
|
-
handleHandshake(ws, msg) {
|
|
582
|
-
if (msg.type === StudioMsg.HELLO) {
|
|
583
|
-
const hello = validateStudioHello(msg.payload);
|
|
584
|
-
if (tokensEqual(hello.token, this.token)) {
|
|
585
|
-
this.authorizeStudio(ws, hello, randomUUID());
|
|
586
|
-
} else {
|
|
587
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
588
|
-
error: "Studio plugin credentials are missing or stale. Run dominus-install-plugin and restart Studio."
|
|
589
|
-
}));
|
|
590
|
-
ws.close(1008, "Unauthorized Studio plugin");
|
|
591
|
-
}
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
if (msg.type === ControllerMsg.HELLO) {
|
|
595
|
-
const payload = msg.payload;
|
|
596
|
-
if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
|
|
597
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, { error: "Invalid Dominus controller credentials" }));
|
|
598
|
-
ws.close(1008, "Unauthorized controller");
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
this.socketRoles.set(ws, "controller");
|
|
602
|
-
this.controllers.add(ws);
|
|
603
|
-
this.sendSafe(ws, createMessage(ControllerMsg.WELCOME, {
|
|
604
|
-
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
605
|
-
port: this.port,
|
|
606
|
-
connections: this.listConnections(),
|
|
607
|
-
activeConnectionId: this.activeConnectionId
|
|
608
|
-
}));
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
612
|
-
error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
|
|
613
|
-
}));
|
|
614
|
-
ws.close(1008, "Invalid Dominus handshake");
|
|
615
|
-
}
|
|
616
|
-
authorizeStudio(ws, hello, connectionId) {
|
|
617
|
-
const connection = {
|
|
618
|
-
ws,
|
|
619
|
-
connectionId,
|
|
620
|
-
clientId: hello.clientId,
|
|
621
|
-
connectedAt: Date.now(),
|
|
622
|
-
studioVersion: hello.studioVersion,
|
|
623
|
-
placeId: hello.placeId ?? 0,
|
|
624
|
-
placeName: hello.placeName
|
|
625
|
-
};
|
|
626
|
-
this.studioClients.set(connectionId, connection);
|
|
627
|
-
this.wsToConnectionId.set(ws, connectionId);
|
|
628
|
-
this.socketRoles.set(ws, "studio");
|
|
629
|
-
if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
|
|
630
|
-
this.activeConnectionId = connectionId;
|
|
631
|
-
}
|
|
632
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTHENTICATED, {
|
|
633
|
-
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
634
|
-
connectionId,
|
|
635
|
-
token: this.token
|
|
636
|
-
}));
|
|
637
|
-
const info = this.toPlaceInfo(connection);
|
|
638
|
-
this.emit("studio:connected", info);
|
|
639
|
-
this.broadcastToControllers(StudioMsg.CONNECTED, info);
|
|
640
|
-
this.broadcastTargetState();
|
|
641
|
-
return connection;
|
|
642
|
-
}
|
|
643
|
-
handleControllerMessage(controllerWs, msg) {
|
|
644
|
-
if (msg.type === ControllerMsg.RELAY) {
|
|
645
|
-
const payload = msg.payload;
|
|
646
|
-
const request = parseMessage(JSON.stringify(payload.request));
|
|
647
|
-
const target = this.resolveConnection(typeof payload.connectionId === "string" ? payload.connectionId : null);
|
|
648
|
-
if (!target) {
|
|
649
|
-
this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
|
|
653
|
-
this.sendSafe(target.ws, request);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
|
|
657
|
-
const payload = msg.payload;
|
|
658
|
-
const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
|
|
659
|
-
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
660
|
-
this.sendResponse(controllerWs, msg.id, { success: false, error: `Studio connection ${connectionId} is not connected` });
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
this.sendResponse(controllerWs, msg.id, { error: `Unsupported controller message: ${msg.type}` });
|
|
667
|
-
}
|
|
668
|
-
handleStudioMessage(ws, msg) {
|
|
669
|
-
if (msg.type === StudioMsg.RESPONSE) {
|
|
670
|
-
const relay = this.relayRequests.get(msg.id);
|
|
671
|
-
if (relay) {
|
|
672
|
-
if (relay.targetWs !== ws) {
|
|
673
|
-
this.emit("server:error", new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`));
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
this.sendSafe(relay.controllerWs, msg);
|
|
677
|
-
this.relayRequests.delete(msg.id);
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const pending = this.pendingRequests.get(msg.id);
|
|
681
|
-
if (pending) {
|
|
682
|
-
if (pending.targetWs !== ws) {
|
|
683
|
-
this.emit("server:error", new Error(`Ignored response ${msg.id} from the wrong Studio session`));
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
clearTimeout(pending.timer);
|
|
687
|
-
this.pendingRequests.delete(msg.id);
|
|
688
|
-
pending.resolve(msg);
|
|
689
|
-
}
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
this.emit(msg.type, msg.payload);
|
|
693
|
-
this.emit("message", msg);
|
|
487
|
+
|
|
488
|
+
// src/transport/daemon-launcher.ts
|
|
489
|
+
function resolveDaemonPath() {
|
|
490
|
+
const besideEntry = fileURLToPath(new URL("./bridge-daemon.js", import.meta.url));
|
|
491
|
+
if (fs4.existsSync(besideEntry)) return besideEntry;
|
|
492
|
+
const projectBuild = path4.resolve(
|
|
493
|
+
path4.dirname(fileURLToPath(import.meta.url)),
|
|
494
|
+
"../../dist/bridge-daemon.js"
|
|
495
|
+
);
|
|
496
|
+
if (fs4.existsSync(projectBuild)) return projectBuild;
|
|
497
|
+
throw new Error("Dominus bridge daemon is missing. Reinstall or rebuild dominus-cli.");
|
|
498
|
+
}
|
|
499
|
+
function launchBridgeDaemon(port, daemonPath = resolveDaemonPath()) {
|
|
500
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
501
|
+
detached: true,
|
|
502
|
+
env: { ...process.env, DOMINUS_BRIDGE_PORT: String(port) },
|
|
503
|
+
stdio: "ignore",
|
|
504
|
+
windowsHide: true
|
|
505
|
+
});
|
|
506
|
+
child.unref();
|
|
507
|
+
}
|
|
508
|
+
async function connectController(port, timeoutMs, token) {
|
|
509
|
+
const client = new DominusClient(port, { connectTimeoutMs: timeoutMs, token });
|
|
510
|
+
try {
|
|
511
|
+
await client.connect();
|
|
512
|
+
return client;
|
|
513
|
+
} catch (error) {
|
|
514
|
+
await client.stop().catch(() => void 0);
|
|
515
|
+
throw error;
|
|
694
516
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if (!connectionId) return;
|
|
708
|
-
const connection = this.studioClients.get(connectionId);
|
|
709
|
-
this.wsToConnectionId.delete(ws);
|
|
710
|
-
this.studioClients.delete(connectionId);
|
|
711
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
712
|
-
if (pending.targetWs === ws) {
|
|
713
|
-
clearTimeout(pending.timer);
|
|
714
|
-
pending.reject(new Error(`Studio disconnected (${connectionId})`));
|
|
715
|
-
this.pendingRequests.delete(id);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
for (const [id, relay] of this.relayRequests) {
|
|
719
|
-
if (relay.targetWs === ws) {
|
|
720
|
-
this.sendResponse(relay.controllerWs, id, { error: `Studio disconnected (${connectionId})` });
|
|
721
|
-
this.relayRequests.delete(id);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
if (this.activeConnectionId === connectionId) {
|
|
725
|
-
this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
|
|
726
|
-
}
|
|
727
|
-
const info = connection ? this.toPlaceInfo(connection) : { connectionId };
|
|
728
|
-
this.emit("studio:disconnected", info);
|
|
729
|
-
this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
|
|
730
|
-
this.broadcastTargetState();
|
|
731
|
-
}
|
|
732
|
-
resolveConnection(connectionId = this.activeConnectionId) {
|
|
733
|
-
if (connectionId) {
|
|
734
|
-
const connection = this.studioClients.get(connectionId);
|
|
735
|
-
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
736
|
-
}
|
|
737
|
-
if (this.studioClients.size === 1) {
|
|
738
|
-
const connection = this.studioClients.values().next().value;
|
|
739
|
-
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
517
|
+
}
|
|
518
|
+
async function ensureBridgeDaemon(port, options = {}) {
|
|
519
|
+
const attempts = options.attempts ?? 30;
|
|
520
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 750;
|
|
521
|
+
const retryDelayMs = options.retryDelayMs ?? 100;
|
|
522
|
+
try {
|
|
523
|
+
return await connectController(port, connectTimeoutMs, options.token);
|
|
524
|
+
} catch (initialError) {
|
|
525
|
+
if (await isPortInUse(port)) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`Port ${port} is occupied by a process that is not this Dominus bridge: ${formatError(initialError)}`
|
|
528
|
+
);
|
|
740
529
|
}
|
|
741
|
-
return null;
|
|
742
|
-
}
|
|
743
|
-
getTargetError() {
|
|
744
|
-
if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
|
|
745
|
-
if (this.activeConnectionId) return `Studio connection ${this.activeConnectionId} is unavailable`;
|
|
746
|
-
return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
|
|
747
530
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
placeName: connection.placeName,
|
|
754
|
-
studioVersion: connection.studioVersion,
|
|
755
|
-
connectedAt: connection.connectedAt,
|
|
756
|
-
active: connection.connectionId === this.activeConnectionId
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
broadcastToControllers(type, payload) {
|
|
760
|
-
const message = createMessage(type, payload);
|
|
761
|
-
for (const controller of this.controllers) this.sendSafe(controller, message);
|
|
762
|
-
}
|
|
763
|
-
broadcastTargetState() {
|
|
764
|
-
this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
|
|
765
|
-
connections: this.listConnections(),
|
|
766
|
-
defaultActiveConnectionId: this.activeConnectionId
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
sendResponse(ws, id, payload) {
|
|
770
|
-
this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
|
|
771
|
-
}
|
|
772
|
-
sendSafe(ws, message) {
|
|
773
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
531
|
+
const launch = options.launch ?? ((targetPort) => launchBridgeDaemon(targetPort, options.daemonPath));
|
|
532
|
+
launch(port);
|
|
533
|
+
let lastError;
|
|
534
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
774
536
|
try {
|
|
775
|
-
|
|
776
|
-
} catch (
|
|
777
|
-
|
|
537
|
+
return await connectController(port, connectTimeoutMs, options.token);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
lastError = error;
|
|
778
540
|
}
|
|
779
541
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
if (!target) {
|
|
784
|
-
reject(new Error(this.getTargetError()));
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
const message = createMessage(type, payload);
|
|
788
|
-
const timer = setTimeout(() => {
|
|
789
|
-
this.pendingRequests.delete(message.id);
|
|
790
|
-
reject(new Error(`Studio request timed out: ${type}`));
|
|
791
|
-
}, timeoutMs);
|
|
792
|
-
this.pendingRequests.set(message.id, {
|
|
793
|
-
resolve,
|
|
794
|
-
reject,
|
|
795
|
-
timer,
|
|
796
|
-
targetWs: target.ws
|
|
797
|
-
});
|
|
798
|
-
this.sendSafe(target.ws, message);
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
sendNotification(type, payload) {
|
|
802
|
-
const target = this.resolveConnection();
|
|
803
|
-
if (target) this.sendSafe(target.ws, createMessage(type, payload));
|
|
804
|
-
}
|
|
805
|
-
isConnected() {
|
|
806
|
-
return this.resolveConnection() !== null;
|
|
807
|
-
}
|
|
808
|
-
getConnectionInfo() {
|
|
809
|
-
return this.resolveConnection();
|
|
810
|
-
}
|
|
811
|
-
listConnections() {
|
|
812
|
-
return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
|
|
813
|
-
}
|
|
814
|
-
getActiveConnectionId() {
|
|
815
|
-
return this.activeConnectionId;
|
|
816
|
-
}
|
|
817
|
-
async setActiveConnectionId(connectionId) {
|
|
818
|
-
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
819
|
-
throw new Error(`Studio connection ${connectionId} is not connected`);
|
|
820
|
-
}
|
|
821
|
-
this.activeConnectionId = connectionId;
|
|
822
|
-
this.broadcastTargetState();
|
|
823
|
-
}
|
|
824
|
-
getActivePlaceId() {
|
|
825
|
-
return this.resolveConnection()?.placeId ?? 0;
|
|
826
|
-
}
|
|
827
|
-
setActivePlaceId(placeId) {
|
|
828
|
-
if (placeId === 0) {
|
|
829
|
-
this.activeConnectionId = null;
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
const matches = [...this.studioClients.values()].filter((connection) => connection.placeId === placeId);
|
|
833
|
-
if (matches.length !== 1) {
|
|
834
|
-
throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
|
|
835
|
-
}
|
|
836
|
-
this.activeConnectionId = matches[0].connectionId;
|
|
837
|
-
}
|
|
838
|
-
getPort() {
|
|
839
|
-
return this.port;
|
|
840
|
-
}
|
|
841
|
-
stop() {
|
|
842
|
-
return new Promise((resolve) => {
|
|
843
|
-
for (const pending of this.pendingRequests.values()) {
|
|
844
|
-
clearTimeout(pending.timer);
|
|
845
|
-
pending.reject(new Error("Dominus bridge is shutting down"));
|
|
846
|
-
}
|
|
847
|
-
this.pendingRequests.clear();
|
|
848
|
-
this.relayRequests.clear();
|
|
849
|
-
for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
|
|
850
|
-
this.studioClients.clear();
|
|
851
|
-
this.controllers.clear();
|
|
852
|
-
this.socketRoles.clear();
|
|
853
|
-
this.wsToConnectionId.clear();
|
|
854
|
-
if (this.wss) this.wss.close(() => resolve());
|
|
855
|
-
else resolve();
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
};
|
|
859
|
-
function validateStudioHello(payload) {
|
|
860
|
-
if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
|
|
861
|
-
const hello = payload;
|
|
862
|
-
if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
|
|
863
|
-
throw new Error(`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`);
|
|
864
|
-
}
|
|
865
|
-
if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
|
|
866
|
-
throw new Error("Studio hello is missing a valid clientId");
|
|
867
|
-
}
|
|
868
|
-
if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
|
|
869
|
-
throw new Error("Studio hello is missing studioVersion");
|
|
870
|
-
}
|
|
871
|
-
if (hello.placeName && hello.placeName.length > 256) throw new Error("Studio placeName is too long");
|
|
872
|
-
return hello;
|
|
542
|
+
throw new Error(
|
|
543
|
+
`Dominus could not start its local Studio bridge on port ${port}: ${formatError(lastError)}`
|
|
544
|
+
);
|
|
873
545
|
}
|
|
874
|
-
function
|
|
875
|
-
|
|
876
|
-
if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
|
|
877
|
-
return raw.byteLength;
|
|
546
|
+
function formatError(error) {
|
|
547
|
+
return error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
878
548
|
}
|
|
879
549
|
|
|
880
550
|
// src/mcp/instructions.ts
|
|
881
551
|
var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
|
|
882
552
|
|
|
883
553
|
Required workflow for Studio changes:
|
|
884
|
-
1. Call dominus_status
|
|
554
|
+
1. Call dominus_status. If Studio is disconnected, ask the user to run dominus-install-plugin only when the Studio Output reports an authentication failure. Select the intended authenticated Studio connection when more than one window is open.
|
|
885
555
|
2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
|
|
886
556
|
3. Form a short plan with explicit acceptance checks.
|
|
887
557
|
4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
|
|
@@ -1164,10 +834,10 @@ async function readReferenceYaml(name, kind, opts) {
|
|
|
1164
834
|
const folder = KIND_FOLDERS[kind];
|
|
1165
835
|
const fileName = `${name}.yaml`;
|
|
1166
836
|
if (localRoot) {
|
|
1167
|
-
const localPath =
|
|
1168
|
-
if (
|
|
837
|
+
const localPath = path4.join(localRoot, folder, fileName);
|
|
838
|
+
if (fs4.existsSync(localPath)) {
|
|
1169
839
|
return {
|
|
1170
|
-
content:
|
|
840
|
+
content: fs4.readFileSync(localPath, "utf-8"),
|
|
1171
841
|
sourceUrl: toCreatorDocsUrl(kind, name)
|
|
1172
842
|
};
|
|
1173
843
|
}
|
|
@@ -1187,11 +857,11 @@ async function listReferenceEntries(opts) {
|
|
|
1187
857
|
if (localRoot) {
|
|
1188
858
|
const entries = [];
|
|
1189
859
|
for (const [kind, folder] of Object.entries(KIND_FOLDERS)) {
|
|
1190
|
-
const dir =
|
|
1191
|
-
if (!
|
|
1192
|
-
for (const file of
|
|
860
|
+
const dir = path4.join(localRoot, folder);
|
|
861
|
+
if (!fs4.existsSync(dir)) continue;
|
|
862
|
+
for (const file of fs4.readdirSync(dir)) {
|
|
1193
863
|
if (!file.endsWith(".yaml")) continue;
|
|
1194
|
-
const name =
|
|
864
|
+
const name = path4.basename(file, ".yaml");
|
|
1195
865
|
entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
|
|
1196
866
|
}
|
|
1197
867
|
}
|
|
@@ -1204,7 +874,7 @@ async function listReferenceEntries(opts) {
|
|
|
1204
874
|
const [folder, file] = rest.split("/");
|
|
1205
875
|
const kind = folderToKind(folder);
|
|
1206
876
|
if (!kind || !file) return [];
|
|
1207
|
-
const name =
|
|
877
|
+
const name = path4.basename(file, ".yaml");
|
|
1208
878
|
return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
|
|
1209
879
|
});
|
|
1210
880
|
}
|
|
@@ -1224,19 +894,19 @@ function resolveDocsRoot(explicitRoot) {
|
|
|
1224
894
|
const candidates = [
|
|
1225
895
|
explicitRoot,
|
|
1226
896
|
process.env.ROBLOX_CREATOR_DOCS_PATH,
|
|
1227
|
-
|
|
897
|
+
path4.join(process.cwd(), ".tmp", "creator-docs")
|
|
1228
898
|
].filter(Boolean);
|
|
1229
899
|
for (const candidate of candidates) {
|
|
1230
900
|
const normalized = normalizeDocsRoot(candidate);
|
|
1231
|
-
if (normalized &&
|
|
901
|
+
if (normalized && fs4.existsSync(normalized)) return normalized;
|
|
1232
902
|
}
|
|
1233
903
|
return null;
|
|
1234
904
|
}
|
|
1235
905
|
function normalizeDocsRoot(root) {
|
|
1236
|
-
const engineRoot =
|
|
1237
|
-
if (
|
|
1238
|
-
const directClasses =
|
|
1239
|
-
if (
|
|
906
|
+
const engineRoot = path4.join(root, "content", "en-us", "reference", "engine");
|
|
907
|
+
if (fs4.existsSync(engineRoot)) return engineRoot;
|
|
908
|
+
const directClasses = path4.join(root, "classes");
|
|
909
|
+
if (fs4.existsSync(directClasses)) return root;
|
|
1240
910
|
return null;
|
|
1241
911
|
}
|
|
1242
912
|
function normalizeLookupName(name) {
|
|
@@ -1905,7 +1575,7 @@ function rejectProposalConflicts(accepted) {
|
|
|
1905
1575
|
const targets = item.proposal.operations.map(operationTarget);
|
|
1906
1576
|
const conflict = claims.find(
|
|
1907
1577
|
(claim) => targets.some(
|
|
1908
|
-
(target) => target.path && claim.paths.some((
|
|
1578
|
+
(target) => target.path && claim.paths.some((path5) => parallelPathsOverlap(target.path, path5)) || claim.refKeys.includes(target.key)
|
|
1909
1579
|
)
|
|
1910
1580
|
);
|
|
1911
1581
|
if (conflict) {
|
|
@@ -2036,7 +1706,7 @@ async function verifyPlan(bridge, rootRef, originalRoot, operations) {
|
|
|
2036
1706
|
const parentPath = resolveRefPath(operation.parent, knownPaths);
|
|
2037
1707
|
return parentPath ? [pathKey([...parentPath, operation.name])] : [];
|
|
2038
1708
|
});
|
|
2039
|
-
const missingCreates = expectedCreates.filter((
|
|
1709
|
+
const missingCreates = expectedCreates.filter((path5) => !paths.has(path5));
|
|
2040
1710
|
const inspectOperations = operations.filter((operation) => operation.op !== "create");
|
|
2041
1711
|
const inspectFailures = [];
|
|
2042
1712
|
const propertyMismatches = [];
|
|
@@ -2204,8 +1874,8 @@ function resolveRefPath(ref2, known) {
|
|
|
2204
1874
|
if (ref2.pathSegments) return ref2.pathSegments;
|
|
2205
1875
|
return ref2.instanceId ? known.get(`id:${ref2.instanceId}`) : void 0;
|
|
2206
1876
|
}
|
|
2207
|
-
function pathKey(
|
|
2208
|
-
return
|
|
1877
|
+
function pathKey(path5) {
|
|
1878
|
+
return path5.join("\0");
|
|
2209
1879
|
}
|
|
2210
1880
|
function parallelPathIsWithin(target, scope) {
|
|
2211
1881
|
return target.length >= scope.length && scope.every((segment, index) => target[index] === segment);
|
|
@@ -2218,8 +1888,8 @@ function samePath(left, right) {
|
|
|
2218
1888
|
left && right && left.length === right.length && left.every((part, index) => part === right[index])
|
|
2219
1889
|
);
|
|
2220
1890
|
}
|
|
2221
|
-
function parallelRootPathToSegments(
|
|
2222
|
-
const trimmed =
|
|
1891
|
+
function parallelRootPathToSegments(path5) {
|
|
1892
|
+
const trimmed = path5.trim();
|
|
2223
1893
|
const bracketSegments = [...trimmed.matchAll(/\["((?:\\.|[^"])*)"\]/g)].map(
|
|
2224
1894
|
(match) => JSON.parse(`"${match[1]}"`)
|
|
2225
1895
|
);
|
|
@@ -2556,7 +2226,7 @@ function registerStudioTools(server, bridge) {
|
|
|
2556
2226
|
}
|
|
2557
2227
|
|
|
2558
2228
|
// src/mcp/server.ts
|
|
2559
|
-
var VERSION = "2.2.
|
|
2229
|
+
var VERSION = "2.2.1";
|
|
2560
2230
|
function createDominusMcpServer(bridge) {
|
|
2561
2231
|
const server = new McpServer(
|
|
2562
2232
|
{
|
|
@@ -2575,22 +2245,7 @@ function createDominusMcpServer(bridge) {
|
|
|
2575
2245
|
return server;
|
|
2576
2246
|
}
|
|
2577
2247
|
async function createStudioBridge(port) {
|
|
2578
|
-
|
|
2579
|
-
const client = new DominusClient(port);
|
|
2580
|
-
await client.connect();
|
|
2581
|
-
return client;
|
|
2582
|
-
}
|
|
2583
|
-
const server = new DominusServer(port);
|
|
2584
|
-
try {
|
|
2585
|
-
await server.start();
|
|
2586
|
-
return server;
|
|
2587
|
-
} catch (err) {
|
|
2588
|
-
const error = err;
|
|
2589
|
-
if (error.code !== "EADDRINUSE") throw err;
|
|
2590
|
-
const client = new DominusClient(port);
|
|
2591
|
-
await client.connect();
|
|
2592
|
-
return client;
|
|
2593
|
-
}
|
|
2248
|
+
return ensureBridgeDaemon(port);
|
|
2594
2249
|
}
|
|
2595
2250
|
async function startMcpServer() {
|
|
2596
2251
|
const config = loadConfig();
|