@chrrxs/robloxstudio-mcp 3.1.5 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2265 -637
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +810 -154
package/dist/index.js
CHANGED
|
@@ -2710,7 +2710,7 @@ var BridgeService = class {
|
|
|
2710
2710
|
if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
|
|
2711
2711
|
return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
|
|
2712
2712
|
}
|
|
2713
|
-
const { promise, resolve:
|
|
2713
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
2714
2714
|
const abortListener = () => {
|
|
2715
2715
|
const pending = this.pendingRequests.get(requestId);
|
|
2716
2716
|
if (pending)
|
|
@@ -2728,7 +2728,7 @@ var BridgeService = class {
|
|
|
2728
2728
|
data,
|
|
2729
2729
|
targetPeerId,
|
|
2730
2730
|
timestamp: now,
|
|
2731
|
-
resolve:
|
|
2731
|
+
resolve: resolve6,
|
|
2732
2732
|
reject,
|
|
2733
2733
|
promise,
|
|
2734
2734
|
timeoutId,
|
|
@@ -3127,19 +3127,20 @@ async function fetchDocCatalog() {
|
|
|
3127
3127
|
}
|
|
3128
3128
|
const controller = new AbortController();
|
|
3129
3129
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3130
|
-
let
|
|
3130
|
+
let html;
|
|
3131
3131
|
try {
|
|
3132
|
-
response = await fetch(DOCS_INDEX_URL, {
|
|
3132
|
+
const response = await fetch(DOCS_INDEX_URL, {
|
|
3133
3133
|
signal: controller.signal,
|
|
3134
3134
|
headers: { Accept: "text/html" }
|
|
3135
3135
|
});
|
|
3136
|
+
if (!response.ok) {
|
|
3137
|
+
await response.body?.cancel();
|
|
3138
|
+
throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
|
|
3139
|
+
}
|
|
3140
|
+
html = await response.text();
|
|
3136
3141
|
} finally {
|
|
3137
3142
|
clearTimeout(timer);
|
|
3138
3143
|
}
|
|
3139
|
-
if (!response.ok) {
|
|
3140
|
-
throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
|
|
3141
|
-
}
|
|
3142
|
-
const html = await response.text();
|
|
3143
3144
|
const pages = /* @__PURE__ */ new Map();
|
|
3144
3145
|
const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
|
|
3145
3146
|
for (const match of html.matchAll(routePattern)) {
|
|
@@ -3192,27 +3193,30 @@ async function fetchRobloxDoc(category, name) {
|
|
|
3192
3193
|
}
|
|
3193
3194
|
const controller = new AbortController();
|
|
3194
3195
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3195
|
-
let response;
|
|
3196
3196
|
try {
|
|
3197
|
-
response = await fetch(docUrl(category, name), {
|
|
3197
|
+
const response = await fetch(docUrl(category, name), {
|
|
3198
3198
|
signal: controller.signal,
|
|
3199
3199
|
headers: { Accept: "text/markdown, text/plain" }
|
|
3200
3200
|
});
|
|
3201
|
+
if (response.status === 404) {
|
|
3202
|
+
await response.body?.cancel();
|
|
3203
|
+
cacheSet(key, { fetchedAt: Date.now(), notFound: true });
|
|
3204
|
+
throw new DocNotFoundError(category, name);
|
|
3205
|
+
}
|
|
3206
|
+
if (!response.ok) {
|
|
3207
|
+
await response.body?.cancel();
|
|
3208
|
+
throw new Error(`HTTP ${response.status}`);
|
|
3209
|
+
}
|
|
3210
|
+
const content = await response.text();
|
|
3211
|
+
cacheSet(key, { fetchedAt: Date.now(), content });
|
|
3212
|
+
return content;
|
|
3201
3213
|
} catch (error) {
|
|
3214
|
+
if (error instanceof DocNotFoundError)
|
|
3215
|
+
throw error;
|
|
3202
3216
|
throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3203
3217
|
} finally {
|
|
3204
3218
|
clearTimeout(timer);
|
|
3205
3219
|
}
|
|
3206
|
-
if (response.status === 404) {
|
|
3207
|
-
cacheSet(key, { fetchedAt: Date.now(), notFound: true });
|
|
3208
|
-
throw new DocNotFoundError(category, name);
|
|
3209
|
-
}
|
|
3210
|
-
if (!response.ok) {
|
|
3211
|
-
throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
|
|
3212
|
-
}
|
|
3213
|
-
const content = await response.text();
|
|
3214
|
-
cacheSet(key, { fetchedAt: Date.now(), content });
|
|
3215
|
-
return content;
|
|
3216
3220
|
}
|
|
3217
3221
|
function listSections(markdown) {
|
|
3218
3222
|
const sections = [];
|
|
@@ -3342,6 +3346,7 @@ Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance,
|
|
|
3342
3346
|
- breakpoints clear removes only MCP-created breakpoints unless clear_all is true. clear_all also removes user-created breakpoints.
|
|
3343
3347
|
- capture_script_profiler ranks Luau functions by CPU time. Use output_path when the raw capture is needed.
|
|
3344
3348
|
- capture_micro_profiler attributes frame time across engine and game work. Its rows are inclusive or cumulative views, so do not sum them as disjoint totals.
|
|
3349
|
+
- MicroProfiler records frames only while Studio renders. Studio stops rendering when the display turns off or Studio is minimized, and the capture then fails with micro_profiler_no_frames.
|
|
3345
3350
|
- Use baseline_path or baseline for before-and-after MicroProfiler comparisons.
|
|
3346
3351
|
- Use get_memory_breakdown for memory categories and get_scene_analysis for instance, script, triangle, animation, or audio cost.
|
|
3347
3352
|
|
|
@@ -3353,6 +3358,16 @@ Studio must allow third-party asset loading for public third-party previews and
|
|
|
3353
3358
|
|
|
3354
3359
|
generate_model stages generated content under ServerStorage for review. upload_asset sends an explicit local file to the chosen Roblox user or group.
|
|
3355
3360
|
|
|
3361
|
+
## Monetization
|
|
3362
|
+
|
|
3363
|
+
manage_monetization lists, reads, creates, and updates developer products and game passes through Roblox Open Cloud. ROBLOX_OPEN_CLOUD_API_KEY needs developer-product:read and developer-product:write, or game-pass:read and game-pass:write, with the experience added to the key. universe_id defaults to the connected Studio place; an unpublished place has no universe.
|
|
3364
|
+
|
|
3365
|
+
- Roblox cannot delete developer products or game passes. To retire one, update it with for_sale=false.
|
|
3366
|
+
- create keeps new items off sale unless for_sale=true, which also needs a price.
|
|
3367
|
+
- create refuses a name that already exists in the universe and returns the existing item. If a create times out, list the universe before retrying.
|
|
3368
|
+
- update changes only the arguments supplied, then reads the item back.
|
|
3369
|
+
- image_path uploads a local PNG, JPEG, or BMP icon. Roblox shows icons at 512x512 inside a circle, so keep details away from the corners.
|
|
3370
|
+
|
|
3356
3371
|
## RBXM files
|
|
3357
3372
|
|
|
3358
3373
|
- export_rbxm writes selected instances to an explicit local path. It can read the edit DataModel or a live server DataModel.
|
|
@@ -3410,18 +3425,104 @@ function registerResourceHandlers(server) {
|
|
|
3410
3425
|
}
|
|
3411
3426
|
|
|
3412
3427
|
// ../core/dist/studio-instance-manager.js
|
|
3413
|
-
import { execFile, execFileSync as execFileSync2, spawn } from "child_process";
|
|
3414
|
-
import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as
|
|
3415
|
-
import { randomUUID as
|
|
3428
|
+
import { execFile, execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
|
|
3429
|
+
import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
|
|
3430
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
3416
3431
|
import * as os2 from "os";
|
|
3417
|
-
import * as
|
|
3432
|
+
import * as path5 from "path";
|
|
3418
3433
|
import { promisify } from "util";
|
|
3419
3434
|
|
|
3435
|
+
// ../core/dist/studio-sign-in.js
|
|
3436
|
+
import { open, readdir } from "fs/promises";
|
|
3437
|
+
import * as path from "path";
|
|
3438
|
+
import { setTimeout as delay } from "timers/promises";
|
|
3439
|
+
var SESSION_LOG = /^[\d.]+_(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z_Studio_[A-Fa-f0-9]+_last\.log$/u;
|
|
3440
|
+
var LINE_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z),/u;
|
|
3441
|
+
var SIGN_IN_START = /\[FLog::StudioKeyEvents\] login \([^)]*\) \[start\]/u;
|
|
3442
|
+
var SIGN_IN_END = /\[FLog::StudioKeyEvents\] login \[end\]\[(success|failure)\]/u;
|
|
3443
|
+
var HEAD_BYTES = 1024 * 1024;
|
|
3444
|
+
var FILETIME_UNIX_EPOCH = 116444736000000000n;
|
|
3445
|
+
function fileTimeToUnixMs(fileTime) {
|
|
3446
|
+
return Number((BigInt(fileTime) - FILETIME_UNIX_EPOCH) / 10000n);
|
|
3447
|
+
}
|
|
3448
|
+
function studioSessionLogStartMs(name) {
|
|
3449
|
+
const match = SESSION_LOG.exec(name);
|
|
3450
|
+
if (!match)
|
|
3451
|
+
return void 0;
|
|
3452
|
+
const [, year, month, day, hour, minute, second] = match.map(Number);
|
|
3453
|
+
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
3454
|
+
}
|
|
3455
|
+
function parseStudioSignIn(name, text) {
|
|
3456
|
+
const session = { name, signingIn: false };
|
|
3457
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
3458
|
+
if (SIGN_IN_START.test(line)) {
|
|
3459
|
+
session.signingIn = true;
|
|
3460
|
+
continue;
|
|
3461
|
+
}
|
|
3462
|
+
const end = SIGN_IN_END.exec(line);
|
|
3463
|
+
if (!end)
|
|
3464
|
+
continue;
|
|
3465
|
+
session.signingIn = false;
|
|
3466
|
+
session.outcome = end[1];
|
|
3467
|
+
const time = LINE_TIME.exec(line)?.[1];
|
|
3468
|
+
if (time !== void 0)
|
|
3469
|
+
session.lastEndMs = Date.parse(time);
|
|
3470
|
+
}
|
|
3471
|
+
return session;
|
|
3472
|
+
}
|
|
3473
|
+
async function readStudioSignInSessions(logsRoot, fromMs, toMs) {
|
|
3474
|
+
let names;
|
|
3475
|
+
try {
|
|
3476
|
+
names = await readdir(logsRoot);
|
|
3477
|
+
} catch (error) {
|
|
3478
|
+
if (error.code === "ENOENT")
|
|
3479
|
+
return [];
|
|
3480
|
+
throw error;
|
|
3481
|
+
}
|
|
3482
|
+
const sessions = [];
|
|
3483
|
+
for (const name of names) {
|
|
3484
|
+
const startedAt = studioSessionLogStartMs(name);
|
|
3485
|
+
if (startedAt === void 0 || startedAt < fromMs || startedAt > toMs)
|
|
3486
|
+
continue;
|
|
3487
|
+
const file = await open(path.join(logsRoot, name), "r");
|
|
3488
|
+
try {
|
|
3489
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
3490
|
+
const { bytesRead } = await file.read(buffer, 0, HEAD_BYTES, 0);
|
|
3491
|
+
sessions.push(parseStudioSignIn(name, buffer.subarray(0, bytesRead).toString("utf8")));
|
|
3492
|
+
} finally {
|
|
3493
|
+
await file.close();
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
return sessions;
|
|
3497
|
+
}
|
|
3498
|
+
async function waitForStudioSignInToSettle({ logsRoot, processStartedAtMs, timeoutMs = 15e3, settleMs = 1500, youngMs = 5e3, now = Date.now, sleep: sleep2 = delay }) {
|
|
3499
|
+
const begin = now();
|
|
3500
|
+
for (; ; ) {
|
|
3501
|
+
const sessions = await readStudioSignInSessions(logsRoot, processStartedAtMs - 2e3, processStartedAtMs + 3e4);
|
|
3502
|
+
const signingIn = sessions.some((session) => session.signingIn);
|
|
3503
|
+
const concluded = sessions.some((session) => session.outcome !== void 0);
|
|
3504
|
+
const undecided = !signingIn && !concluded && now() - processStartedAtMs < youngMs;
|
|
3505
|
+
if (!signingIn && !undecided) {
|
|
3506
|
+
const ends = sessions.flatMap((session) => session.lastEndMs === void 0 ? [] : [session.lastEndMs]);
|
|
3507
|
+
if (!ends.length)
|
|
3508
|
+
return "idle";
|
|
3509
|
+
const sinceEnd = now() - Math.max(...ends);
|
|
3510
|
+
const remaining = Math.min(settleMs, Math.max(0, settleMs - sinceEnd));
|
|
3511
|
+
if (remaining > 0)
|
|
3512
|
+
await sleep2(remaining);
|
|
3513
|
+
return "settled";
|
|
3514
|
+
}
|
|
3515
|
+
if (now() - begin >= timeoutMs)
|
|
3516
|
+
return "timeout";
|
|
3517
|
+
await sleep2(250);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3420
3521
|
// ../core/dist/managed-instance-registry.js
|
|
3421
3522
|
import * as fs from "fs/promises";
|
|
3422
3523
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
3423
3524
|
import * as os from "os";
|
|
3424
|
-
import * as
|
|
3525
|
+
import * as path2 from "path";
|
|
3425
3526
|
import { setTimeout as delayTimer } from "timers/promises";
|
|
3426
3527
|
var REGISTRY_VERSION = 1;
|
|
3427
3528
|
var LOCK_STALE_MS = 1e4;
|
|
@@ -3438,14 +3539,14 @@ function defaultManagedInstanceRegistryDir() {
|
|
|
3438
3539
|
return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
|
|
3439
3540
|
}
|
|
3440
3541
|
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
|
|
3441
|
-
return
|
|
3542
|
+
return path2.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
|
|
3442
3543
|
}
|
|
3443
3544
|
if (process.platform === "darwin") {
|
|
3444
|
-
return
|
|
3545
|
+
return path2.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
|
|
3445
3546
|
}
|
|
3446
|
-
return
|
|
3547
|
+
return path2.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
|
|
3447
3548
|
}
|
|
3448
|
-
function
|
|
3549
|
+
function delay2(ms) {
|
|
3449
3550
|
return delayTimer(ms, void 0, { ref: false });
|
|
3450
3551
|
}
|
|
3451
3552
|
function ymd(timestamp) {
|
|
@@ -3540,7 +3641,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3540
3641
|
}
|
|
3541
3642
|
async withLock(fn) {
|
|
3542
3643
|
await this.ensureDir();
|
|
3543
|
-
const lockDir =
|
|
3644
|
+
const lockDir = path2.join(this.dir, ".lock");
|
|
3544
3645
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
3545
3646
|
const owner = {
|
|
3546
3647
|
pid: process.pid,
|
|
@@ -3553,7 +3654,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3553
3654
|
activeLockTokens.add(owner.token);
|
|
3554
3655
|
activeLockPaths.add(lockDir);
|
|
3555
3656
|
try {
|
|
3556
|
-
await fs.writeFile(
|
|
3657
|
+
await fs.writeFile(path2.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
3557
3658
|
`, {
|
|
3558
3659
|
encoding: "utf8",
|
|
3559
3660
|
flag: "wx"
|
|
@@ -3574,7 +3675,9 @@ var ManagedInstanceRegistry = class {
|
|
|
3574
3675
|
const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
|
|
3575
3676
|
const ownerIsActive = currentOwner?.pid === process.pid ? activeLockTokens.has(currentOwner.token) : currentOwner ? isProcessAlive(currentOwner.pid) : void 0;
|
|
3576
3677
|
if (ownerIsActive === false || currentOwner === void 0 && !activeLockPaths.has(lockDir) && Date.now() - stat2.mtimeMs > LOCK_STALE_MS) {
|
|
3577
|
-
await
|
|
3678
|
+
if (await this.isInspectedLock(lockDir, stat2, currentOwner)) {
|
|
3679
|
+
await fs.rm(lockDir, { recursive: true, force: true });
|
|
3680
|
+
}
|
|
3578
3681
|
continue;
|
|
3579
3682
|
}
|
|
3580
3683
|
} catch {
|
|
@@ -3583,7 +3686,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3583
3686
|
if (Date.now() > deadline) {
|
|
3584
3687
|
throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
|
|
3585
3688
|
}
|
|
3586
|
-
await
|
|
3689
|
+
await delay2(LOCK_RETRY_MS);
|
|
3587
3690
|
}
|
|
3588
3691
|
}
|
|
3589
3692
|
try {
|
|
@@ -3604,21 +3707,31 @@ var ManagedInstanceRegistry = class {
|
|
|
3604
3707
|
}
|
|
3605
3708
|
async readLockOwner(lockPath, isDirectory) {
|
|
3606
3709
|
try {
|
|
3607
|
-
const contents = await fs.readFile(isDirectory ?
|
|
3710
|
+
const contents = await fs.readFile(isDirectory ? path2.join(lockPath, "owner.json") : lockPath, "utf8");
|
|
3608
3711
|
const parsed = JSON.parse(contents);
|
|
3609
3712
|
return isLockOwner(parsed) ? parsed : void 0;
|
|
3610
3713
|
} catch {
|
|
3611
3714
|
return void 0;
|
|
3612
3715
|
}
|
|
3613
3716
|
}
|
|
3717
|
+
// The inspected owner can release, and another holder can take the lock, while this
|
|
3718
|
+
// waiter decides. Reclaim only the instance that was judged abandoned: the same owner
|
|
3719
|
+
// token, or for an unreadable owner the same unchanged directory entry.
|
|
3720
|
+
async isInspectedLock(lockPath, inspected, inspectedOwner) {
|
|
3721
|
+
const current = await fs.stat(lockPath);
|
|
3722
|
+
const currentOwner = await this.readLockOwner(lockPath, current.isDirectory());
|
|
3723
|
+
if (inspectedOwner)
|
|
3724
|
+
return currentOwner?.token === inspectedOwner.token;
|
|
3725
|
+
return currentOwner === void 0 && current.ino === inspected.ino && current.mtimeMs === inspected.mtimeMs;
|
|
3726
|
+
}
|
|
3614
3727
|
async ensureDir() {
|
|
3615
3728
|
await fs.mkdir(this.dir, { recursive: true });
|
|
3616
3729
|
}
|
|
3617
3730
|
recordPath(recordId) {
|
|
3618
|
-
return
|
|
3731
|
+
return path2.join(this.dir, `${recordId}.json`);
|
|
3619
3732
|
}
|
|
3620
3733
|
async recordFilesUnlocked() {
|
|
3621
|
-
return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) =>
|
|
3734
|
+
return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path2.join(this.dir, name));
|
|
3622
3735
|
}
|
|
3623
3736
|
async readRecordUnlocked(recordId) {
|
|
3624
3737
|
try {
|
|
@@ -3647,7 +3760,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3647
3760
|
async writeRecordUnlocked(record) {
|
|
3648
3761
|
await this.ensureDir();
|
|
3649
3762
|
const finalPath = this.recordPath(record.recordId);
|
|
3650
|
-
const tmpPath =
|
|
3763
|
+
const tmpPath = path2.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
|
|
3651
3764
|
const fd = await fs.open(tmpPath, "w");
|
|
3652
3765
|
try {
|
|
3653
3766
|
await fd.writeFile(`${JSON.stringify(record, null, 2)}
|
|
@@ -3662,7 +3775,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3662
3775
|
await fs.rm(this.recordPath(recordId), { force: true });
|
|
3663
3776
|
}
|
|
3664
3777
|
async appendEventUnlocked(event, now) {
|
|
3665
|
-
const file =
|
|
3778
|
+
const file = path2.join(this.dir, `events-${ymd(now)}.jsonl`);
|
|
3666
3779
|
await fs.appendFile(file, `${JSON.stringify({
|
|
3667
3780
|
ts: new Date(now).toISOString(),
|
|
3668
3781
|
...event
|
|
@@ -3676,7 +3789,7 @@ var ManagedInstanceRegistry = class {
|
|
|
3676
3789
|
if (!date || date >= cutoff)
|
|
3677
3790
|
continue;
|
|
3678
3791
|
try {
|
|
3679
|
-
await fs.rm(
|
|
3792
|
+
await fs.rm(path2.join(this.dir, name), { force: true });
|
|
3680
3793
|
} catch {
|
|
3681
3794
|
}
|
|
3682
3795
|
}
|
|
@@ -3819,7 +3932,8 @@ var ManagedInstanceRegistry = class {
|
|
|
3819
3932
|
|
|
3820
3933
|
// ../core/dist/studio-platform.js
|
|
3821
3934
|
import { execFileSync } from "child_process";
|
|
3822
|
-
import { existsSync, readFileSync } from "fs";
|
|
3935
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from "fs";
|
|
3936
|
+
import * as path3 from "path";
|
|
3823
3937
|
var WSL_KERNEL_PATTERN = /microsoft|wsl/i;
|
|
3824
3938
|
var WINDOWS_INTEROP_PROBE_TOKEN = "ROBLOXSTUDIO_MCP_WINDOWS_INTEROP_OK";
|
|
3825
3939
|
var WINDOWS_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
|
|
@@ -3860,7 +3974,20 @@ function detectStudioPlatform(evidence) {
|
|
|
3860
3974
|
}
|
|
3861
3975
|
};
|
|
3862
3976
|
}
|
|
3863
|
-
|
|
3977
|
+
if (!hasWslKernel && evidence.wineLauncher && evidence.wineLauncherExecutable === true) {
|
|
3978
|
+
return {
|
|
3979
|
+
hostPlatform: "linux",
|
|
3980
|
+
isWsl: false,
|
|
3981
|
+
windowsInteropAvailable: false,
|
|
3982
|
+
processIdentity: {
|
|
3983
|
+
supported: true,
|
|
3984
|
+
launcher: "wine-retained"
|
|
3985
|
+
},
|
|
3986
|
+
// Wine hosts are always Linux; keep POSIX semantics when tests run elsewhere.
|
|
3987
|
+
wineLauncher: path3.posix.resolve(evidence.wineLauncher)
|
|
3988
|
+
};
|
|
3989
|
+
}
|
|
3990
|
+
const reason = hasWslKernel ? "The Linux kernel has a WSL signature, but the live process cannot execute the Windows launcher." : evidence.wineLauncher ? `The retained Windows Studio launcher is unavailable on native Linux, and ROBLOX_STUDIO_WINE_LAUNCHER (${evidence.wineLauncher}) is not an executable file.` : "The retained Windows Studio launcher is unavailable on native Linux. Set ROBLOX_STUDIO_WINE_LAUNCHER to an executable Wine launcher to run Studio under Wine.";
|
|
3864
3991
|
return {
|
|
3865
3992
|
hostPlatform: "linux",
|
|
3866
3993
|
isWsl: false,
|
|
@@ -3910,11 +4037,22 @@ function probeWindowsInterop(kernelVersion) {
|
|
|
3910
4037
|
}
|
|
3911
4038
|
return false;
|
|
3912
4039
|
}
|
|
4040
|
+
function isExecutableFile(file) {
|
|
4041
|
+
try {
|
|
4042
|
+
if (!statSync(file).isFile())
|
|
4043
|
+
return false;
|
|
4044
|
+
accessSync(file, constants.X_OK);
|
|
4045
|
+
return true;
|
|
4046
|
+
} catch {
|
|
4047
|
+
return false;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
3913
4050
|
var cachedStudioPlatformCapabilities;
|
|
3914
4051
|
function getStudioPlatformCapabilities() {
|
|
3915
4052
|
if (cachedStudioPlatformCapabilities)
|
|
3916
4053
|
return cachedStudioPlatformCapabilities;
|
|
3917
4054
|
const kernelVersion = readKernelVersion();
|
|
4055
|
+
const wineLauncher = process.env.ROBLOX_STUDIO_WINE_LAUNCHER || void 0;
|
|
3918
4056
|
cachedStudioPlatformCapabilities = detectStudioPlatform({
|
|
3919
4057
|
platform: process.platform,
|
|
3920
4058
|
kernelVersion,
|
|
@@ -3922,13 +4060,435 @@ function getStudioPlatformCapabilities() {
|
|
|
3922
4060
|
wslDistroName: process.env.WSL_DISTRO_NAME,
|
|
3923
4061
|
windowsRootPresent: existsSync("/mnt/c/Windows"),
|
|
3924
4062
|
wslPathPresent: existsSync("/usr/bin/wslpath") || existsSync("/bin/wslpath"),
|
|
3925
|
-
windowsInteropAvailable: probeWindowsInterop(kernelVersion)
|
|
4063
|
+
windowsInteropAvailable: probeWindowsInterop(kernelVersion),
|
|
4064
|
+
wineLauncher,
|
|
4065
|
+
wineLauncherExecutable: process.platform === "linux" && wineLauncher !== void 0 && isExecutableFile(wineLauncher)
|
|
3926
4066
|
});
|
|
3927
4067
|
return cachedStudioPlatformCapabilities;
|
|
3928
4068
|
}
|
|
3929
4069
|
|
|
4070
|
+
// ../core/dist/studio-wine.js
|
|
4071
|
+
import { spawn } from "child_process";
|
|
4072
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4073
|
+
import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync as readFileSync2, readSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
4074
|
+
import * as path4 from "path";
|
|
4075
|
+
var DEFAULT_PROC_ROOT = "/proc";
|
|
4076
|
+
var STUDIO_EXECUTABLE_NAME = "robloxstudiobeta.exe";
|
|
4077
|
+
var GATE_SCRIPT = 'kill -STOP $$ && exec "$0" "$@"';
|
|
4078
|
+
var GATE_TIMEOUT_MS = 5e3;
|
|
4079
|
+
var STUDIO_EXEC_TIMEOUT_MS = 6e4;
|
|
4080
|
+
var MAX_STUDIO_EXEC_TIMEOUT_MS = 15e4;
|
|
4081
|
+
var STUDIO_EXEC_TIMEOUT_ENV = "ROBLOX_STUDIO_WINE_START_TIMEOUT_MS";
|
|
4082
|
+
var ABORT_TIMEOUT_MS = 1e4;
|
|
4083
|
+
var TERMINATE_GRACE_MS = 5e3;
|
|
4084
|
+
var POLL_INTERVAL_MS = 50;
|
|
4085
|
+
var LAUNCHER_LOGS_KEPT = 10;
|
|
4086
|
+
var LAUNCHER_LOG_TAIL_BYTES = 4096;
|
|
4087
|
+
var LAUNCHER_LOG_TAIL_LINES = 20;
|
|
4088
|
+
var BOOT_ANCHOR_PREFIX = "wine-boot-time-";
|
|
4089
|
+
var FILETIME_UNIX_EPOCH2 = 116444736000000000n;
|
|
4090
|
+
var FILETIME_TICKS_PER_SECOND = 10000000n;
|
|
4091
|
+
var USER_HZ = 100n;
|
|
4092
|
+
var bootTimeByProcRoot = /* @__PURE__ */ new Map();
|
|
4093
|
+
function delay3(ms) {
|
|
4094
|
+
const { promise, resolve: resolve6 } = Promise.withResolvers();
|
|
4095
|
+
setTimeout(resolve6, ms);
|
|
4096
|
+
return promise;
|
|
4097
|
+
}
|
|
4098
|
+
function isMissingProcessError(error) {
|
|
4099
|
+
const code = error?.code;
|
|
4100
|
+
return code === "ENOENT" || code === "ESRCH";
|
|
4101
|
+
}
|
|
4102
|
+
function procStartTimeToFileTime(bootTimeSeconds2, startTimeTicks) {
|
|
4103
|
+
return (FILETIME_UNIX_EPOCH2 + bootTimeSeconds2 * FILETIME_TICKS_PER_SECOND + startTimeTicks * (FILETIME_TICKS_PER_SECOND / USER_HZ)).toString();
|
|
4104
|
+
}
|
|
4105
|
+
function toWineWindowsPath(unixPath) {
|
|
4106
|
+
return `Z:${unixPath.replace(/\//g, "\\")}`;
|
|
4107
|
+
}
|
|
4108
|
+
function isWineStudioCommand(argv0) {
|
|
4109
|
+
return argv0 !== void 0 && argv0.split(/[\\/]/u).pop()?.toLowerCase() === STUDIO_EXECUTABLE_NAME;
|
|
4110
|
+
}
|
|
4111
|
+
function parseProcStat(content) {
|
|
4112
|
+
const commEnd = content.lastIndexOf(")");
|
|
4113
|
+
const fields = commEnd < 0 ? [] : content.slice(commEnd + 1).trim().split(/\s+/u);
|
|
4114
|
+
const state = fields[0];
|
|
4115
|
+
const startTime = fields[19];
|
|
4116
|
+
if (!state || !startTime || !/^\d+$/u.test(startTime)) {
|
|
4117
|
+
throw new Error("Malformed /proc/<pid>/stat.");
|
|
4118
|
+
}
|
|
4119
|
+
return { state, startTimeTicks: BigInt(startTime) };
|
|
4120
|
+
}
|
|
4121
|
+
function readProcStat(procRoot, pid) {
|
|
4122
|
+
let content;
|
|
4123
|
+
try {
|
|
4124
|
+
content = readFileSync2(`${procRoot}/${pid}/stat`, "utf8");
|
|
4125
|
+
} catch (error) {
|
|
4126
|
+
if (isMissingProcessError(error))
|
|
4127
|
+
return void 0;
|
|
4128
|
+
throw error;
|
|
4129
|
+
}
|
|
4130
|
+
return parseProcStat(content);
|
|
4131
|
+
}
|
|
4132
|
+
function readCmdline(procRoot, pid) {
|
|
4133
|
+
let content;
|
|
4134
|
+
try {
|
|
4135
|
+
content = readFileSync2(`${procRoot}/${pid}/cmdline`, "utf8");
|
|
4136
|
+
} catch (error) {
|
|
4137
|
+
if (isMissingProcessError(error))
|
|
4138
|
+
return void 0;
|
|
4139
|
+
throw error;
|
|
4140
|
+
}
|
|
4141
|
+
const args = content.split("\0");
|
|
4142
|
+
if (args[args.length - 1] === "")
|
|
4143
|
+
args.pop();
|
|
4144
|
+
return args;
|
|
4145
|
+
}
|
|
4146
|
+
function readArgv0(procRoot, pid) {
|
|
4147
|
+
return readCmdline(procRoot, pid)?.[0] || void 0;
|
|
4148
|
+
}
|
|
4149
|
+
function readRealUid(procRoot, pid) {
|
|
4150
|
+
let content;
|
|
4151
|
+
try {
|
|
4152
|
+
content = readFileSync2(`${procRoot}/${pid}/status`, "utf8");
|
|
4153
|
+
} catch (error) {
|
|
4154
|
+
if (isMissingProcessError(error))
|
|
4155
|
+
return void 0;
|
|
4156
|
+
throw error;
|
|
4157
|
+
}
|
|
4158
|
+
const match = /^Uid:\s+(\d+)/mu.exec(content);
|
|
4159
|
+
if (!match)
|
|
4160
|
+
throw new Error(`Malformed ${procRoot}/${pid}/status.`);
|
|
4161
|
+
return Number(match[1]);
|
|
4162
|
+
}
|
|
4163
|
+
function readLiveBootTime(procRoot) {
|
|
4164
|
+
const match = /^btime\s+(\d+)\s*$/mu.exec(readFileSync2(`${procRoot}/stat`, "utf8"));
|
|
4165
|
+
if (!match)
|
|
4166
|
+
throw new Error(`${procRoot}/stat has no btime.`);
|
|
4167
|
+
return BigInt(match[1]);
|
|
4168
|
+
}
|
|
4169
|
+
function readBootId(procRoot) {
|
|
4170
|
+
try {
|
|
4171
|
+
const bootId = readFileSync2(`${procRoot}/sys/kernel/random/boot_id`, "utf8").trim();
|
|
4172
|
+
return /^[0-9a-f-]+$/iu.test(bootId) ? bootId : void 0;
|
|
4173
|
+
} catch {
|
|
4174
|
+
return void 0;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
function readBootAnchor(file) {
|
|
4178
|
+
let content;
|
|
4179
|
+
try {
|
|
4180
|
+
content = readFileSync2(file, "utf8");
|
|
4181
|
+
} catch (error) {
|
|
4182
|
+
if (error.code === "ENOENT")
|
|
4183
|
+
return void 0;
|
|
4184
|
+
throw error;
|
|
4185
|
+
}
|
|
4186
|
+
const match = /^(\d+)\n?$/u.exec(content);
|
|
4187
|
+
if (!match)
|
|
4188
|
+
throw new Error(`${file} is not a boot time.`);
|
|
4189
|
+
return BigInt(match[1]);
|
|
4190
|
+
}
|
|
4191
|
+
function removeStaleBootAnchors(anchorDir, currentName) {
|
|
4192
|
+
try {
|
|
4193
|
+
for (const name of readdirSync(anchorDir)) {
|
|
4194
|
+
if (!name.startsWith(BOOT_ANCHOR_PREFIX) || name.startsWith(currentName))
|
|
4195
|
+
continue;
|
|
4196
|
+
rmSync(path4.join(anchorDir, name), { force: true });
|
|
4197
|
+
}
|
|
4198
|
+
} catch {
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
function anchoredBootTime(procRoot, liveBootTime) {
|
|
4202
|
+
const bootId = readBootId(procRoot);
|
|
4203
|
+
if (bootId === void 0)
|
|
4204
|
+
return liveBootTime;
|
|
4205
|
+
const anchorDir = defaultManagedInstanceRegistryDir();
|
|
4206
|
+
const anchorName = `${BOOT_ANCHOR_PREFIX}${bootId}`;
|
|
4207
|
+
const anchorFile = path4.join(anchorDir, anchorName);
|
|
4208
|
+
try {
|
|
4209
|
+
const existing = readBootAnchor(anchorFile);
|
|
4210
|
+
if (existing !== void 0)
|
|
4211
|
+
return existing;
|
|
4212
|
+
mkdirSync(anchorDir, { recursive: true });
|
|
4213
|
+
const tmpFile = `${anchorFile}.${process.pid}.${randomUUID3()}.tmp`;
|
|
4214
|
+
writeFileSync(tmpFile, `${liveBootTime}
|
|
4215
|
+
`);
|
|
4216
|
+
try {
|
|
4217
|
+
linkSync(tmpFile, anchorFile);
|
|
4218
|
+
} catch (error) {
|
|
4219
|
+
if (error.code !== "EEXIST")
|
|
4220
|
+
throw error;
|
|
4221
|
+
} finally {
|
|
4222
|
+
unlinkSync(tmpFile);
|
|
4223
|
+
}
|
|
4224
|
+
removeStaleBootAnchors(anchorDir, anchorName);
|
|
4225
|
+
return readBootAnchor(anchorFile) ?? liveBootTime;
|
|
4226
|
+
} catch (error) {
|
|
4227
|
+
console.error(`[studio] Could not persist the Wine boot-time anchor at ${anchorFile}: ${error instanceof Error ? error.message : String(error)}. Studio identities may not survive a broker restart after a clock change.`);
|
|
4228
|
+
return liveBootTime;
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
function bootTimeSeconds(procRoot) {
|
|
4232
|
+
const cached = bootTimeByProcRoot.get(procRoot);
|
|
4233
|
+
if (cached !== void 0)
|
|
4234
|
+
return cached;
|
|
4235
|
+
const bootTime = anchoredBootTime(procRoot, readLiveBootTime(procRoot));
|
|
4236
|
+
bootTimeByProcRoot.set(procRoot, bootTime);
|
|
4237
|
+
return bootTime;
|
|
4238
|
+
}
|
|
4239
|
+
function readWineProcessStartTime(pid, procRoot = DEFAULT_PROC_ROOT) {
|
|
4240
|
+
const stat2 = readProcStat(procRoot, pid);
|
|
4241
|
+
if (!stat2 || stat2.state === "Z" || stat2.state === "X")
|
|
4242
|
+
return void 0;
|
|
4243
|
+
return procStartTimeToFileTime(bootTimeSeconds(procRoot), stat2.startTimeTicks);
|
|
4244
|
+
}
|
|
4245
|
+
function listWineStudioProcesses(procRoot = DEFAULT_PROC_ROOT, uid = process.getuid?.()) {
|
|
4246
|
+
const processes = [];
|
|
4247
|
+
for (const entry of readdirSync(procRoot)) {
|
|
4248
|
+
if (!/^\d+$/u.test(entry))
|
|
4249
|
+
continue;
|
|
4250
|
+
const pid = Number(entry);
|
|
4251
|
+
let argv0;
|
|
4252
|
+
let startedAt;
|
|
4253
|
+
try {
|
|
4254
|
+
if (uid !== void 0 && readRealUid(procRoot, pid) !== uid)
|
|
4255
|
+
continue;
|
|
4256
|
+
argv0 = readArgv0(procRoot, pid);
|
|
4257
|
+
if (!isWineStudioCommand(argv0))
|
|
4258
|
+
continue;
|
|
4259
|
+
startedAt = readWineProcessStartTime(pid, procRoot);
|
|
4260
|
+
} catch (error) {
|
|
4261
|
+
if (error.code === "EACCES" || isMissingProcessError(error))
|
|
4262
|
+
continue;
|
|
4263
|
+
throw error;
|
|
4264
|
+
}
|
|
4265
|
+
if (startedAt === void 0)
|
|
4266
|
+
continue;
|
|
4267
|
+
processes.push({
|
|
4268
|
+
Id: pid,
|
|
4269
|
+
Name: "RobloxStudioBeta",
|
|
4270
|
+
Path: argv0,
|
|
4271
|
+
MainWindowTitle: "",
|
|
4272
|
+
StartTimeUtcFileTime: startedAt
|
|
4273
|
+
});
|
|
4274
|
+
}
|
|
4275
|
+
return processes;
|
|
4276
|
+
}
|
|
4277
|
+
function signalProcess(pid, signal) {
|
|
4278
|
+
try {
|
|
4279
|
+
process.kill(pid, signal);
|
|
4280
|
+
} catch (error) {
|
|
4281
|
+
if (!isMissingProcessError(error))
|
|
4282
|
+
throw error;
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
async function stopWineStudio(pid, startedAt, timeoutMs, procRoot = DEFAULT_PROC_ROOT) {
|
|
4286
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
4287
|
+
throw new Error("processId must be a positive integer.");
|
|
4288
|
+
if (!/^[1-9]\d*$/u.test(startedAt))
|
|
4289
|
+
throw new Error("startedAt must be a positive FILETIME string.");
|
|
4290
|
+
const isTarget = () => readWineProcessStartTime(pid, procRoot) === startedAt;
|
|
4291
|
+
if (!isTarget())
|
|
4292
|
+
return;
|
|
4293
|
+
signalProcess(pid, "SIGTERM");
|
|
4294
|
+
const graceDeadline = Date.now() + Math.min(TERMINATE_GRACE_MS, timeoutMs / 2);
|
|
4295
|
+
while (Date.now() < graceDeadline) {
|
|
4296
|
+
await delay3(POLL_INTERVAL_MS);
|
|
4297
|
+
if (!isTarget())
|
|
4298
|
+
return;
|
|
4299
|
+
}
|
|
4300
|
+
if (isTarget())
|
|
4301
|
+
signalProcess(pid, "SIGKILL");
|
|
4302
|
+
}
|
|
4303
|
+
function killOrphanedWineLaunchGate(pid, startedAt, procRoot = DEFAULT_PROC_ROOT) {
|
|
4304
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
4305
|
+
return false;
|
|
4306
|
+
const stat2 = readProcStat(procRoot, pid);
|
|
4307
|
+
if (stat2?.state !== "T")
|
|
4308
|
+
return false;
|
|
4309
|
+
if (procStartTimeToFileTime(bootTimeSeconds(procRoot), stat2.startTimeTicks) !== startedAt)
|
|
4310
|
+
return false;
|
|
4311
|
+
const cmdline = readCmdline(procRoot, pid);
|
|
4312
|
+
if (cmdline?.[1] !== "-c" || cmdline[2] !== GATE_SCRIPT)
|
|
4313
|
+
return false;
|
|
4314
|
+
signalProcess(pid, "SIGKILL");
|
|
4315
|
+
return true;
|
|
4316
|
+
}
|
|
4317
|
+
function studioExecTimeoutMs(explicit) {
|
|
4318
|
+
if (explicit !== void 0)
|
|
4319
|
+
return explicit;
|
|
4320
|
+
const configured = process.env[STUDIO_EXEC_TIMEOUT_ENV];
|
|
4321
|
+
if (configured === void 0 || configured === "")
|
|
4322
|
+
return STUDIO_EXEC_TIMEOUT_MS;
|
|
4323
|
+
const value = /^\d+$/u.test(configured) ? Number(configured) : NaN;
|
|
4324
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_STUDIO_EXEC_TIMEOUT_MS) {
|
|
4325
|
+
throw new Error(`${STUDIO_EXEC_TIMEOUT_ENV} must be a whole number of milliseconds from 1 to ${MAX_STUDIO_EXEC_TIMEOUT_MS}; got "${configured}".`);
|
|
4326
|
+
}
|
|
4327
|
+
return value;
|
|
4328
|
+
}
|
|
4329
|
+
function openLauncherLog(logDir) {
|
|
4330
|
+
try {
|
|
4331
|
+
mkdirSync(logDir, { recursive: true });
|
|
4332
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, "-");
|
|
4333
|
+
const file = path4.join(logDir, `${stamp}-${randomUUID3().slice(0, 8)}.log`);
|
|
4334
|
+
const fd = openSync(file, "a", 384);
|
|
4335
|
+
const logs = readdirSync(logDir).filter((name) => name.endsWith(".log")).sort();
|
|
4336
|
+
for (const name of logs.slice(0, Math.max(0, logs.length - LAUNCHER_LOGS_KEPT))) {
|
|
4337
|
+
rmSync(path4.join(logDir, name), { force: true });
|
|
4338
|
+
}
|
|
4339
|
+
return { file, fd };
|
|
4340
|
+
} catch (error) {
|
|
4341
|
+
console.error(`[studio] Could not open a Wine launcher log in ${logDir}; launcher output is discarded: ${error instanceof Error ? error.message : String(error)}`);
|
|
4342
|
+
return void 0;
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
function launcherLogTail(file) {
|
|
4346
|
+
if (file === void 0)
|
|
4347
|
+
return "";
|
|
4348
|
+
let tail;
|
|
4349
|
+
try {
|
|
4350
|
+
const fd = openSync(file, "r");
|
|
4351
|
+
try {
|
|
4352
|
+
const size = fstatSync(fd).size;
|
|
4353
|
+
const length = Math.min(size, LAUNCHER_LOG_TAIL_BYTES);
|
|
4354
|
+
const buffer = Buffer.alloc(length);
|
|
4355
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
4356
|
+
tail = buffer.toString("utf8");
|
|
4357
|
+
} finally {
|
|
4358
|
+
closeSync(fd);
|
|
4359
|
+
}
|
|
4360
|
+
} catch {
|
|
4361
|
+
return ` Launcher log: ${file}.`;
|
|
4362
|
+
}
|
|
4363
|
+
const lines = tail.trimEnd().split("\n").slice(-LAUNCHER_LOG_TAIL_LINES).join("\n");
|
|
4364
|
+
return lines ? ` Launcher output (${file}):
|
|
4365
|
+
${lines}` : ` The launcher wrote no output (log: ${file}).`;
|
|
4366
|
+
}
|
|
4367
|
+
async function spawnWineStudio(launcher, exe, args, spawnOptions, options = {}) {
|
|
4368
|
+
const procRoot = options.procRoot ?? DEFAULT_PROC_ROOT;
|
|
4369
|
+
const execTimeoutMs = studioExecTimeoutMs(options.studioExecTimeoutMs);
|
|
4370
|
+
const log = options.logDir === void 0 ? void 0 : openLauncherLog(options.logDir);
|
|
4371
|
+
let child;
|
|
4372
|
+
try {
|
|
4373
|
+
child = spawn("/bin/sh", ["-c", GATE_SCRIPT, launcher, exe, ...args], {
|
|
4374
|
+
cwd: spawnOptions.cwd,
|
|
4375
|
+
env: spawnOptions.env,
|
|
4376
|
+
detached: true,
|
|
4377
|
+
stdio: log ? ["ignore", log.fd, log.fd] : "ignore"
|
|
4378
|
+
});
|
|
4379
|
+
} finally {
|
|
4380
|
+
if (log)
|
|
4381
|
+
closeSync(log.fd);
|
|
4382
|
+
}
|
|
4383
|
+
const logTail = () => launcherLogTail(log?.file);
|
|
4384
|
+
let exitDetail;
|
|
4385
|
+
const { promise: exited, resolve: markExited } = Promise.withResolvers();
|
|
4386
|
+
child.once("exit", (code, signal) => {
|
|
4387
|
+
exitDetail ??= signal ? `signal ${signal}` : `code ${code}`;
|
|
4388
|
+
markExited();
|
|
4389
|
+
});
|
|
4390
|
+
child.once("error", (error) => {
|
|
4391
|
+
exitDetail ??= error.message;
|
|
4392
|
+
});
|
|
4393
|
+
const pid = child.pid;
|
|
4394
|
+
if (pid === void 0) {
|
|
4395
|
+
await delay3(0);
|
|
4396
|
+
throw new Error(`Failed to start the Wine Studio launch gate: ${exitDetail ?? "no PID was assigned"}.`);
|
|
4397
|
+
}
|
|
4398
|
+
const gateDeadline = Date.now() + (options.gateTimeoutMs ?? GATE_TIMEOUT_MS);
|
|
4399
|
+
let gated;
|
|
4400
|
+
try {
|
|
4401
|
+
while (exitDetail === void 0 && Date.now() < gateDeadline) {
|
|
4402
|
+
const stat2 = readProcStat(procRoot, pid);
|
|
4403
|
+
if (stat2?.state === "T") {
|
|
4404
|
+
gated = stat2;
|
|
4405
|
+
break;
|
|
4406
|
+
}
|
|
4407
|
+
await delay3(10);
|
|
4408
|
+
}
|
|
4409
|
+
} catch (error) {
|
|
4410
|
+
child.kill("SIGKILL");
|
|
4411
|
+
throw error;
|
|
4412
|
+
}
|
|
4413
|
+
if (!gated) {
|
|
4414
|
+
child.kill("SIGKILL");
|
|
4415
|
+
throw new Error(exitDetail === void 0 ? "Timed out waiting for the Wine Studio launch gate to stop; the gated process was killed." : `Wine Studio launch gate exited before stopping (${exitDetail}).${logTail()}`);
|
|
4416
|
+
}
|
|
4417
|
+
const nativeStartedAt = procStartTimeToFileTime(bootTimeSeconds(procRoot), gated.startTimeTicks);
|
|
4418
|
+
let controlState = "pending";
|
|
4419
|
+
const authorize = async () => {
|
|
4420
|
+
if (controlState === "resumed")
|
|
4421
|
+
return;
|
|
4422
|
+
if (controlState !== "pending") {
|
|
4423
|
+
throw new Error(`Studio launch cannot be authorized from ${controlState}.`);
|
|
4424
|
+
}
|
|
4425
|
+
controlState = "authorizing";
|
|
4426
|
+
child.kill("SIGCONT");
|
|
4427
|
+
const deadline = Date.now() + execTimeoutMs;
|
|
4428
|
+
try {
|
|
4429
|
+
while (exitDetail === void 0 && Date.now() < deadline) {
|
|
4430
|
+
if (readWineProcessStartTime(pid, procRoot) === nativeStartedAt && isWineStudioCommand(readArgv0(procRoot, pid))) {
|
|
4431
|
+
controlState = "resumed";
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
await delay3(POLL_INTERVAL_MS);
|
|
4435
|
+
}
|
|
4436
|
+
} catch (error) {
|
|
4437
|
+
controlState = "aborted";
|
|
4438
|
+
child.kill("SIGKILL");
|
|
4439
|
+
throw error;
|
|
4440
|
+
}
|
|
4441
|
+
controlState = "aborted";
|
|
4442
|
+
if (exitDetail !== void 0) {
|
|
4443
|
+
throw new Error(`Wine Studio process ${pid} exited (${exitDetail}) before RobloxStudioBeta.exe started.${logTail()}`);
|
|
4444
|
+
}
|
|
4445
|
+
child.kill("SIGKILL");
|
|
4446
|
+
throw new Error(`Wine launcher did not exec RobloxStudioBeta.exe in gated process ${pid} within ${execTimeoutMs} ms; the process was killed. ROBLOX_STUDIO_WINE_LAUNCHER must exec Wine so Studio keeps the launcher PID; if Wine is only slow to start, raise ${STUDIO_EXEC_TIMEOUT_ENV}.${logTail()}`);
|
|
4447
|
+
};
|
|
4448
|
+
const release = async () => {
|
|
4449
|
+
if (controlState === "released")
|
|
4450
|
+
return;
|
|
4451
|
+
if (controlState !== "resumed") {
|
|
4452
|
+
throw new Error(`Studio launch cannot be released from ${controlState}.`);
|
|
4453
|
+
}
|
|
4454
|
+
controlState = "released";
|
|
4455
|
+
};
|
|
4456
|
+
const abort = async () => {
|
|
4457
|
+
if (controlState === "released")
|
|
4458
|
+
return;
|
|
4459
|
+
controlState = "aborted";
|
|
4460
|
+
if (exitDetail !== void 0)
|
|
4461
|
+
return;
|
|
4462
|
+
child.kill("SIGKILL");
|
|
4463
|
+
const { promise: timedOut, resolve: markTimedOut } = Promise.withResolvers();
|
|
4464
|
+
const timer = setTimeout(() => markTimedOut(true), ABORT_TIMEOUT_MS);
|
|
4465
|
+
try {
|
|
4466
|
+
if (await Promise.race([exited.then(() => false), timedOut])) {
|
|
4467
|
+
throw new Error(`Timed out aborting Wine Studio process ${pid}.`);
|
|
4468
|
+
}
|
|
4469
|
+
} finally {
|
|
4470
|
+
clearTimeout(timer);
|
|
4471
|
+
}
|
|
4472
|
+
};
|
|
4473
|
+
return {
|
|
4474
|
+
pid,
|
|
4475
|
+
nativePid: pid,
|
|
4476
|
+
nativeStartedAt,
|
|
4477
|
+
unref: () => child.unref(),
|
|
4478
|
+
authorize,
|
|
4479
|
+
release,
|
|
4480
|
+
abort,
|
|
4481
|
+
onExit: (listener) => {
|
|
4482
|
+
child.once("exit", listener);
|
|
4483
|
+
},
|
|
4484
|
+
onError: (listener) => {
|
|
4485
|
+
child.once("error", listener);
|
|
4486
|
+
}
|
|
4487
|
+
};
|
|
4488
|
+
}
|
|
4489
|
+
|
|
3930
4490
|
// ../core/dist/studio-instance-manager.js
|
|
3931
|
-
var BASEPLATE_TEMP_DIR =
|
|
4491
|
+
var BASEPLATE_TEMP_DIR = path5.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
|
|
3932
4492
|
var BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
|
|
3933
4493
|
var BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
|
|
3934
4494
|
var retainedLaunchControls = /* @__PURE__ */ new Map();
|
|
@@ -4011,6 +4571,9 @@ async function runAsync(command, args, options = {}) {
|
|
|
4011
4571
|
function isWsl() {
|
|
4012
4572
|
return getStudioPlatformCapabilities().isWsl;
|
|
4013
4573
|
}
|
|
4574
|
+
function isWineHost() {
|
|
4575
|
+
return getStudioPlatformCapabilities().processIdentity.launcher === "wine-retained";
|
|
4576
|
+
}
|
|
4014
4577
|
async function powershellAsync(script, timeoutMs = 15e3) {
|
|
4015
4578
|
return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
4016
4579
|
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
@@ -4054,7 +4617,9 @@ async function toWslPathAsync(windowsPath) {
|
|
|
4054
4617
|
return runAsync("wslpath", ["-u", windowsPath]);
|
|
4055
4618
|
}
|
|
4056
4619
|
async function toStudioLaunchArgAsync(arg) {
|
|
4057
|
-
if (
|
|
4620
|
+
if (isWineHost() && path5.isAbsolute(arg) && existsSync2(arg))
|
|
4621
|
+
return toWineWindowsPath(arg);
|
|
4622
|
+
if (!isWsl() || !path5.isAbsolute(arg) || !existsSync2(arg))
|
|
4058
4623
|
return arg;
|
|
4059
4624
|
return runAsync("wslpath", ["-w", arg]);
|
|
4060
4625
|
}
|
|
@@ -4374,7 +4939,8 @@ public sealed class McpSuspendedStudio : IDisposable
|
|
|
4374
4939
|
|
|
4375
4940
|
private static IntPtr OpenTestWorkerJob(string name)
|
|
4376
4941
|
{
|
|
4377
|
-
|
|
4942
|
+
// PowerShell passes $null to a .NET string parameter as "", so both mean "no worker job".
|
|
4943
|
+
if (String.IsNullOrEmpty(name)) return IntPtr.Zero;
|
|
4378
4944
|
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"\\ALocal\\\\RsmcpStudioWorker-[a-f0-9]{32}\\z"))
|
|
4379
4945
|
throw new ArgumentException("Invalid Studio test worker job name");
|
|
4380
4946
|
IntPtr handle = OpenJobObjectW(5, false, name);
|
|
@@ -4546,6 +5112,19 @@ function buildWindowsStudioStopScript(processId, startedAt) {
|
|
|
4546
5112
|
"} finally { if ($null -ne $studio) { $studio.Dispose() } }"
|
|
4547
5113
|
].join("; ");
|
|
4548
5114
|
}
|
|
5115
|
+
var studioLogsRoot;
|
|
5116
|
+
async function settleWindowsStudioSignIn(startedAt) {
|
|
5117
|
+
studioLogsRoot ??= (async () => {
|
|
5118
|
+
const localAppData = await windowsLocalAppDataAsync();
|
|
5119
|
+
return localAppData ? path5.join(await toWslPathAsync(localAppData), "Roblox", "logs") : void 0;
|
|
5120
|
+
})();
|
|
5121
|
+
const logsRoot = await studioLogsRoot;
|
|
5122
|
+
if (!logsRoot)
|
|
5123
|
+
return;
|
|
5124
|
+
const result = await waitForStudioSignInToSettle({ logsRoot, processStartedAtMs: fileTimeToUnixMs(startedAt) });
|
|
5125
|
+
if (result === "timeout")
|
|
5126
|
+
console.error("[studio] Studio was still signing in after 15 s; closing it anyway.");
|
|
5127
|
+
}
|
|
4549
5128
|
async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
|
|
4550
5129
|
await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
|
|
4551
5130
|
}
|
|
@@ -4554,12 +5133,12 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
|
|
|
4554
5133
|
const windowsWorkingDirectory = studioWorkingDirectory ? await toStudioLaunchArgAsync(studioWorkingDirectory) : void 0;
|
|
4555
5134
|
const testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB;
|
|
4556
5135
|
const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName);
|
|
4557
|
-
const launcher =
|
|
5136
|
+
const launcher = spawn2("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
4558
5137
|
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
4559
5138
|
stdio: ["pipe", "pipe", "pipe"],
|
|
4560
5139
|
windowsHide: true
|
|
4561
5140
|
});
|
|
4562
|
-
return new Promise((
|
|
5141
|
+
return new Promise((resolve6, reject) => {
|
|
4563
5142
|
let stdout = "";
|
|
4564
5143
|
let controlOutput = "";
|
|
4565
5144
|
let stderr = "";
|
|
@@ -4706,7 +5285,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
|
|
|
4706
5285
|
throw new Error("PowerShell returned an invalid native Studio process identity.");
|
|
4707
5286
|
}
|
|
4708
5287
|
identity = { pid: nativePid, startedAt: nativeStartedAt };
|
|
4709
|
-
|
|
5288
|
+
resolve6({
|
|
4710
5289
|
pid: nativePid,
|
|
4711
5290
|
nativePid,
|
|
4712
5291
|
nativeStartedAt,
|
|
@@ -4774,21 +5353,21 @@ function resolveEntrypointDir() {
|
|
|
4774
5353
|
if (!entrypoint)
|
|
4775
5354
|
return void 0;
|
|
4776
5355
|
try {
|
|
4777
|
-
return
|
|
5356
|
+
return path5.dirname(realpathSync(entrypoint));
|
|
4778
5357
|
} catch {
|
|
4779
|
-
return
|
|
5358
|
+
return path5.dirname(path5.resolve(entrypoint));
|
|
4780
5359
|
}
|
|
4781
5360
|
}
|
|
4782
5361
|
function resolveBaseplateTemplatePath() {
|
|
4783
5362
|
const entrypointDir = resolveEntrypointDir();
|
|
4784
5363
|
const candidates = [
|
|
4785
5364
|
...entrypointDir ? [
|
|
4786
|
-
|
|
4787
|
-
|
|
5365
|
+
path5.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
|
|
5366
|
+
path5.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
|
|
4788
5367
|
] : [],
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
5368
|
+
path5.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
5369
|
+
path5.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
|
|
5370
|
+
path5.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
|
|
4792
5371
|
];
|
|
4793
5372
|
for (const candidate of candidates) {
|
|
4794
5373
|
if (existsSync2(candidate))
|
|
@@ -4810,7 +5389,7 @@ function isProcessAlive2(pid) {
|
|
|
4810
5389
|
function sweepStaleBaseplateFiles() {
|
|
4811
5390
|
let entries;
|
|
4812
5391
|
try {
|
|
4813
|
-
entries =
|
|
5392
|
+
entries = readdirSync2(BASEPLATE_TEMP_DIR);
|
|
4814
5393
|
} catch {
|
|
4815
5394
|
return;
|
|
4816
5395
|
}
|
|
@@ -4821,24 +5400,24 @@ function sweepStaleBaseplateFiles() {
|
|
|
4821
5400
|
continue;
|
|
4822
5401
|
if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
|
|
4823
5402
|
continue;
|
|
4824
|
-
const file =
|
|
5403
|
+
const file = path5.join(BASEPLATE_TEMP_DIR, entry);
|
|
4825
5404
|
try {
|
|
4826
|
-
if (
|
|
4827
|
-
|
|
5405
|
+
if (statSync2(file).mtimeMs < cutoff)
|
|
5406
|
+
rmSync2(file, { force: true });
|
|
4828
5407
|
} catch {
|
|
4829
5408
|
}
|
|
4830
5409
|
}
|
|
4831
5410
|
}
|
|
4832
5411
|
function createBaseplatePlaceFile() {
|
|
4833
|
-
|
|
5412
|
+
mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
|
|
4834
5413
|
sweepStaleBaseplateFiles();
|
|
4835
|
-
const file =
|
|
5414
|
+
const file = path5.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
|
|
4836
5415
|
copyFileSync(resolveBaseplateTemplatePath(), file);
|
|
4837
5416
|
return file;
|
|
4838
5417
|
}
|
|
4839
5418
|
function isGeneratedBaseplatePlaceFile(file) {
|
|
4840
|
-
const resolvedFile =
|
|
4841
|
-
return
|
|
5419
|
+
const resolvedFile = path5.resolve(file);
|
|
5420
|
+
return path5.dirname(resolvedFile) === path5.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path5.basename(resolvedFile));
|
|
4842
5421
|
}
|
|
4843
5422
|
function cleanupManagedBaseplateFiles(record) {
|
|
4844
5423
|
if (record.source !== "baseplate" || !record.localPlaceFile)
|
|
@@ -4847,7 +5426,7 @@ function cleanupManagedBaseplateFiles(record) {
|
|
|
4847
5426
|
return;
|
|
4848
5427
|
for (const file of [record.localPlaceFile, `${record.localPlaceFile}.lock`]) {
|
|
4849
5428
|
try {
|
|
4850
|
-
|
|
5429
|
+
rmSync2(file, { force: true });
|
|
4851
5430
|
} catch {
|
|
4852
5431
|
}
|
|
4853
5432
|
}
|
|
@@ -4864,21 +5443,33 @@ function resolveStudioExeFromVersions(root) {
|
|
|
4864
5443
|
if (!existsSync2(root)) {
|
|
4865
5444
|
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4866
5445
|
}
|
|
4867
|
-
const candidates =
|
|
5446
|
+
const candidates = readdirSync2(root).filter((name) => name.startsWith("version-")).map((name) => path5.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs);
|
|
4868
5447
|
if (candidates.length === 0) {
|
|
4869
5448
|
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
4870
5449
|
}
|
|
4871
5450
|
const exe = candidates[0];
|
|
4872
|
-
const installationDir =
|
|
4873
|
-
const hasIncompleteDownload =
|
|
4874
|
-
const settings =
|
|
5451
|
+
const installationDir = path5.dirname(exe);
|
|
5452
|
+
const hasIncompleteDownload = readdirSync2(installationDir).some((name) => /\.crdownload$/i.test(name));
|
|
5453
|
+
const settings = statSync2(path5.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
|
|
4875
5454
|
if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
|
|
4876
5455
|
const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
|
|
4877
5456
|
throw new Error(`Roblox Studio installation/update is incomplete at ${installationDir}: ${reason}. Complete the installation/update or reinstall Roblox Studio under the owning Windows account before launching.`);
|
|
4878
5457
|
}
|
|
4879
5458
|
return exe;
|
|
4880
5459
|
}
|
|
5460
|
+
function resolveNativeLinuxStudioExe() {
|
|
5461
|
+
const exe = process.env.ROBLOX_STUDIO_EXE;
|
|
5462
|
+
if (!exe) {
|
|
5463
|
+
throw new Error("Set ROBLOX_STUDIO_EXE to RobloxStudioBeta.exe inside your Wine prefix to launch Studio on native Linux.");
|
|
5464
|
+
}
|
|
5465
|
+
if (!statSync2(exe, { throwIfNoEntry: false })?.isFile()) {
|
|
5466
|
+
throw new Error(`ROBLOX_STUDIO_EXE does not name an existing file: ${exe}`);
|
|
5467
|
+
}
|
|
5468
|
+
return exe;
|
|
5469
|
+
}
|
|
4881
5470
|
function resolveStudioExe() {
|
|
5471
|
+
if (process.platform === "linux" && isWineHost())
|
|
5472
|
+
return resolveNativeLinuxStudioExe();
|
|
4882
5473
|
if (process.env.ROBLOX_STUDIO_EXE)
|
|
4883
5474
|
return process.env.ROBLOX_STUDIO_EXE;
|
|
4884
5475
|
if (process.platform === "darwin") {
|
|
@@ -4888,10 +5479,12 @@ function resolveStudioExe() {
|
|
|
4888
5479
|
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
4889
5480
|
}
|
|
4890
5481
|
const localAppData = windowsLocalAppData();
|
|
4891
|
-
const root = localAppData ?
|
|
5482
|
+
const root = localAppData ? path5.join(toWslPath(localAppData), "Roblox", "Versions") : path5.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
4892
5483
|
return resolveStudioExeFromVersions(root);
|
|
4893
5484
|
}
|
|
4894
5485
|
async function resolveStudioExeAsync() {
|
|
5486
|
+
if (process.platform === "linux" && isWineHost())
|
|
5487
|
+
return resolveNativeLinuxStudioExe();
|
|
4895
5488
|
if (process.env.ROBLOX_STUDIO_EXE)
|
|
4896
5489
|
return process.env.ROBLOX_STUDIO_EXE;
|
|
4897
5490
|
if (process.platform === "darwin") {
|
|
@@ -4901,7 +5494,7 @@ async function resolveStudioExeAsync() {
|
|
|
4901
5494
|
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
4902
5495
|
}
|
|
4903
5496
|
const localAppData = await windowsLocalAppDataAsync();
|
|
4904
|
-
const root = localAppData ?
|
|
5497
|
+
const root = localAppData ? path5.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path5.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
4905
5498
|
return resolveStudioExeFromVersions(root);
|
|
4906
5499
|
}
|
|
4907
5500
|
var WINDOWS_STUDIO_PROCESS_QUERY = [
|
|
@@ -4938,6 +5531,9 @@ async function observeStudioProcesses(timeoutMs = 15e3) {
|
|
|
4938
5531
|
}).filter((proc) => Number.isFinite(proc.Id));
|
|
4939
5532
|
return { status: "ok", observedAt, processes };
|
|
4940
5533
|
}
|
|
5534
|
+
if (process.platform === "linux" && isWineHost()) {
|
|
5535
|
+
return { status: "ok", observedAt, processes: listWineStudioProcesses() };
|
|
5536
|
+
}
|
|
4941
5537
|
if (process.platform !== "win32" && !isWsl()) {
|
|
4942
5538
|
return { status: "ok", observedAt, processes: [] };
|
|
4943
5539
|
}
|
|
@@ -4954,7 +5550,7 @@ async function observeStudioProcesses(timeoutMs = 15e3) {
|
|
|
4954
5550
|
async function currentBootIdAsync() {
|
|
4955
5551
|
if (process.platform === "linux") {
|
|
4956
5552
|
try {
|
|
4957
|
-
return
|
|
5553
|
+
return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
4958
5554
|
} catch {
|
|
4959
5555
|
}
|
|
4960
5556
|
}
|
|
@@ -5005,11 +5601,11 @@ function buildStudioLaunchArgs(options) {
|
|
|
5005
5601
|
];
|
|
5006
5602
|
}
|
|
5007
5603
|
}
|
|
5008
|
-
function
|
|
5009
|
-
return new Promise((
|
|
5604
|
+
function delay4(ms) {
|
|
5605
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
5010
5606
|
}
|
|
5011
5607
|
function basenameAny(filePath) {
|
|
5012
|
-
return
|
|
5608
|
+
return path5.basename(filePath.replace(/\\/g, "/"));
|
|
5013
5609
|
}
|
|
5014
5610
|
var StudioInstanceManager = class {
|
|
5015
5611
|
managedByInstanceId = /* @__PURE__ */ new Map();
|
|
@@ -5026,6 +5622,7 @@ var StudioInstanceManager = class {
|
|
|
5026
5622
|
snapshotCacheMs;
|
|
5027
5623
|
launchCompletionTimeoutMs;
|
|
5028
5624
|
closeTimeoutMs;
|
|
5625
|
+
settleSignIn;
|
|
5029
5626
|
coordinatorTimer;
|
|
5030
5627
|
coordinatorRefresh;
|
|
5031
5628
|
cachedSnapshot;
|
|
@@ -5041,6 +5638,7 @@ var StudioInstanceManager = class {
|
|
|
5041
5638
|
this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
|
|
5042
5639
|
this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
|
|
5043
5640
|
this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
|
|
5641
|
+
this.settleSignIn = options.settleSignIn ?? (!this.processAdapter.stopProcess && (process.platform === "win32" || isWsl()) ? settleWindowsStudioSignIn : void 0);
|
|
5044
5642
|
}
|
|
5045
5643
|
getLifecycleCapabilities() {
|
|
5046
5644
|
const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
|
|
@@ -5220,8 +5818,8 @@ var StudioInstanceManager = class {
|
|
|
5220
5818
|
async launch(options) {
|
|
5221
5819
|
const previous = this.launchQueue;
|
|
5222
5820
|
let release;
|
|
5223
|
-
this.launchQueue = new Promise((
|
|
5224
|
-
release =
|
|
5821
|
+
this.launchQueue = new Promise((resolve6) => {
|
|
5822
|
+
release = resolve6;
|
|
5225
5823
|
});
|
|
5226
5824
|
await previous;
|
|
5227
5825
|
try {
|
|
@@ -5259,8 +5857,15 @@ var StudioInstanceManager = class {
|
|
|
5259
5857
|
proc = await this.processAdapter.spawnStudio(exe, args, spawnOptions);
|
|
5260
5858
|
} else if (lifecycleCapabilities.processIdentity.launcher === "windows-retained" || lifecycleCapabilities.processIdentity.launcher === "wsl-windows-retained") {
|
|
5261
5859
|
proc = await this.windowsStudioLauncher(exe, args, processEnvironment, studioWorkingDirectory);
|
|
5860
|
+
} else if (lifecycleCapabilities.processIdentity.launcher === "wine-retained") {
|
|
5861
|
+
const wineLauncher = (this.platformCapabilities ?? getStudioPlatformCapabilities()).wineLauncher;
|
|
5862
|
+
if (!wineLauncher)
|
|
5863
|
+
throw new Error("The Wine Studio launcher path is unavailable.");
|
|
5864
|
+
proc = await spawnWineStudio(wineLauncher, exe, args, spawnOptions, {
|
|
5865
|
+
logDir: path5.join(this.registry.dir, "wine-launches")
|
|
5866
|
+
});
|
|
5262
5867
|
} else {
|
|
5263
|
-
const child =
|
|
5868
|
+
const child = spawn2(exe, args, spawnOptions);
|
|
5264
5869
|
proc = {
|
|
5265
5870
|
pid: child.pid,
|
|
5266
5871
|
nativePid: child.pid,
|
|
@@ -5310,7 +5915,7 @@ var StudioInstanceManager = class {
|
|
|
5310
5915
|
}
|
|
5311
5916
|
const launchedAt = Date.now();
|
|
5312
5917
|
const record = {
|
|
5313
|
-
recordId:
|
|
5918
|
+
recordId: randomUUID4(),
|
|
5314
5919
|
source: options.source,
|
|
5315
5920
|
nativeProcessId: proc.nativePid,
|
|
5316
5921
|
nativeProcessStartedAt: proc.nativeStartedAt,
|
|
@@ -5380,7 +5985,7 @@ var StudioInstanceManager = class {
|
|
|
5380
5985
|
await this.persist(record);
|
|
5381
5986
|
break;
|
|
5382
5987
|
}
|
|
5383
|
-
await
|
|
5988
|
+
await delay4(250);
|
|
5384
5989
|
}
|
|
5385
5990
|
if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
|
|
5386
5991
|
record.nativeProcessId = proc.pid;
|
|
@@ -5438,7 +6043,7 @@ var StudioInstanceManager = class {
|
|
|
5438
6043
|
instanceId: record.instanceId
|
|
5439
6044
|
};
|
|
5440
6045
|
}
|
|
5441
|
-
|
|
6046
|
+
let deadline = Date.now() + this.closeTimeoutMs;
|
|
5442
6047
|
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
5443
6048
|
if (!processId) {
|
|
5444
6049
|
throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
|
|
@@ -5477,6 +6082,8 @@ var StudioInstanceManager = class {
|
|
|
5477
6082
|
};
|
|
5478
6083
|
}
|
|
5479
6084
|
}
|
|
6085
|
+
if (!abort)
|
|
6086
|
+
deadline = await this.beforeStudioStop(record.nativeProcessStartedAt, deadline);
|
|
5480
6087
|
try {
|
|
5481
6088
|
await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
|
|
5482
6089
|
} catch (error) {
|
|
@@ -5525,7 +6132,7 @@ var StudioInstanceManager = class {
|
|
|
5525
6132
|
};
|
|
5526
6133
|
}
|
|
5527
6134
|
async closeConnectedInstance(instance) {
|
|
5528
|
-
|
|
6135
|
+
let deadline = Date.now() + this.closeTimeoutMs;
|
|
5529
6136
|
const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
|
|
5530
6137
|
if (snapshot.status === "error") {
|
|
5531
6138
|
throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
|
|
@@ -5534,8 +6141,20 @@ var StudioInstanceManager = class {
|
|
|
5534
6141
|
if (!process2) {
|
|
5535
6142
|
throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
|
|
5536
6143
|
}
|
|
6144
|
+
deadline = await this.beforeStudioStop(process2.StartTimeUtcFileTime, deadline);
|
|
5537
6145
|
await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
|
|
5538
6146
|
}
|
|
6147
|
+
async beforeStudioStop(startedAt, deadline) {
|
|
6148
|
+
if (!this.settleSignIn || startedAt === void 0 || !/^[1-9]\d*$/u.test(startedAt))
|
|
6149
|
+
return deadline;
|
|
6150
|
+
const waitStarted = Date.now();
|
|
6151
|
+
try {
|
|
6152
|
+
await this.settleSignIn(startedAt);
|
|
6153
|
+
} catch (error) {
|
|
6154
|
+
console.error(`[studio] Could not check Studio sign-in before closing: ${error instanceof Error ? error.message : String(error)}`);
|
|
6155
|
+
}
|
|
6156
|
+
return deadline + (Date.now() - waitStarted);
|
|
6157
|
+
}
|
|
5539
6158
|
async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
|
|
5540
6159
|
this.cachedSnapshot = void 0;
|
|
5541
6160
|
await beforeCloseDeadline(async () => {
|
|
@@ -5548,6 +6167,11 @@ var StudioInstanceManager = class {
|
|
|
5548
6167
|
throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
|
|
5549
6168
|
}
|
|
5550
6169
|
await stopWindowsStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
|
|
6170
|
+
} else if (isWineHost()) {
|
|
6171
|
+
if (startedAt === void 0) {
|
|
6172
|
+
throw new Error("Cannot stop a Wine Studio process without its creation-time identity.");
|
|
6173
|
+
}
|
|
6174
|
+
await stopWineStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
|
|
5551
6175
|
} else {
|
|
5552
6176
|
try {
|
|
5553
6177
|
process.kill(processId, "SIGTERM");
|
|
@@ -5557,7 +6181,7 @@ var StudioInstanceManager = class {
|
|
|
5557
6181
|
}
|
|
5558
6182
|
}
|
|
5559
6183
|
}, deadline, processId);
|
|
5560
|
-
|
|
6184
|
+
for (; ; ) {
|
|
5561
6185
|
const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
|
|
5562
6186
|
if (observation.status === "not_running")
|
|
5563
6187
|
return;
|
|
@@ -5568,7 +6192,7 @@ var StudioInstanceManager = class {
|
|
|
5568
6192
|
if (remaining <= 0) {
|
|
5569
6193
|
throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
|
|
5570
6194
|
}
|
|
5571
|
-
await
|
|
6195
|
+
await delay4(Math.min(100, remaining));
|
|
5572
6196
|
if (Date.now() >= deadline) {
|
|
5573
6197
|
throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
|
|
5574
6198
|
}
|
|
@@ -5665,15 +6289,39 @@ var StudioInstanceManager = class {
|
|
|
5665
6289
|
confirmedExitGraceMs: this.confirmedExitGraceMs
|
|
5666
6290
|
};
|
|
5667
6291
|
}
|
|
6292
|
+
isOrphanedUnreleasedLaunch(registryRecord, currentBootId) {
|
|
6293
|
+
const ownerPid = registryRecord.ownerPid;
|
|
6294
|
+
return !(registryRecord.processAuthorizationState === "released" || this.launchControls.has(registryRecord.recordId) || registryRecord.bootId !== currentBootId || ownerPid === void 0 || ownerPid !== process.pid && isProcessAlive2(ownerPid));
|
|
6295
|
+
}
|
|
6296
|
+
// A broker that dies between spawn and authorization leaves its Wine launch
|
|
6297
|
+
// gate stopped. The gate is /bin/sh, not Studio, so the orphan cleanup below
|
|
6298
|
+
// never sees it in a Studio snapshot.
|
|
6299
|
+
async reapOrphanedWineLaunchGates(currentBootId) {
|
|
6300
|
+
if (this.getLifecycleCapabilities().processIdentity.launcher !== "wine-retained")
|
|
6301
|
+
return;
|
|
6302
|
+
for (const registryRecord of await this.registry.listOpenUnchecked()) {
|
|
6303
|
+
const processId = registryRecord.nativeProcessId;
|
|
6304
|
+
const startedAt = registryRecord.nativeProcessStartedAt;
|
|
6305
|
+
if (registryRecord.processAuthorizationState !== "pending" || !processId || !startedAt || !this.isOrphanedUnreleasedLaunch(registryRecord, currentBootId)) {
|
|
6306
|
+
continue;
|
|
6307
|
+
}
|
|
6308
|
+
try {
|
|
6309
|
+
if (killOrphanedWineLaunchGate(processId, startedAt)) {
|
|
6310
|
+
console.error(`[studio] Killed Wine launch gate ${processId}, left stopped by a broker that exited before authorizing it.`);
|
|
6311
|
+
}
|
|
6312
|
+
} catch (error) {
|
|
6313
|
+
console.error(`[studio] Could not check orphaned Wine launch gate ${processId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
5668
6317
|
async sweepRegistry(snapshot) {
|
|
5669
6318
|
const sweepOptions = await this.registrySweepOptions(snapshot);
|
|
6319
|
+
await this.reapOrphanedWineLaunchGates(sweepOptions.currentBootId);
|
|
5670
6320
|
await this.registry.sweep(sweepOptions);
|
|
5671
6321
|
const persisted = await this.registry.listOpenUnchecked();
|
|
5672
6322
|
for (const registryRecord of persisted) {
|
|
5673
|
-
|
|
5674
|
-
if (registryRecord.processAuthorizationState === "released" || this.launchControls.has(registryRecord.recordId) || registryRecord.bootId !== sweepOptions.currentBootId || ownerPid === void 0 || ownerPid !== process.pid && isProcessAlive2(ownerPid)) {
|
|
6323
|
+
if (!this.isOrphanedUnreleasedLaunch(registryRecord, sweepOptions.currentBootId))
|
|
5675
6324
|
continue;
|
|
5676
|
-
}
|
|
5677
6325
|
const record = this.fromRegistryRecord(registryRecord);
|
|
5678
6326
|
if (this.observeRecord(record, snapshot).status !== "running")
|
|
5679
6327
|
continue;
|
|
@@ -5736,13 +6384,13 @@ var StudioInstanceManager = class {
|
|
|
5736
6384
|
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
5737
6385
|
if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
|
|
5738
6386
|
return true;
|
|
5739
|
-
const processPath = studioProcess.Path ?
|
|
5740
|
-
const exePath = record.exe ?
|
|
6387
|
+
const processPath = studioProcess.Path ? path5.normalize(studioProcess.Path).toLowerCase() : "";
|
|
6388
|
+
const exePath = record.exe ? path5.normalize(record.exe).toLowerCase() : "";
|
|
5741
6389
|
if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
|
|
5742
6390
|
return true;
|
|
5743
6391
|
}
|
|
5744
6392
|
const commandLine = studioProcess.CommandLine ?? "";
|
|
5745
|
-
if (record.localPlaceFile && commandLine.includes(
|
|
6393
|
+
if (record.localPlaceFile && commandLine.includes(path5.basename(record.localPlaceFile)))
|
|
5746
6394
|
return true;
|
|
5747
6395
|
if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
|
|
5748
6396
|
return true;
|
|
@@ -6061,6 +6709,7 @@ var OPEN_WORLD_TOOLS = /* @__PURE__ */ new Set([
|
|
|
6061
6709
|
"get_asset_thumbnail",
|
|
6062
6710
|
"get_roblox_docs",
|
|
6063
6711
|
"insert_asset",
|
|
6712
|
+
"manage_monetization",
|
|
6064
6713
|
"preview_asset",
|
|
6065
6714
|
"search_assets",
|
|
6066
6715
|
"upload_asset"
|
|
@@ -6273,11 +6922,21 @@ function createToolHttpHandler(options) {
|
|
|
6273
6922
|
|
|
6274
6923
|
// ../core/dist/auth.js
|
|
6275
6924
|
import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
|
|
6276
|
-
import {
|
|
6277
|
-
import { join as
|
|
6925
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, linkSync as linkSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
6926
|
+
import { join as join5, dirname as dirname2 } from "path";
|
|
6278
6927
|
import { homedir as homedir3 } from "os";
|
|
6279
6928
|
function authTokenFilePath() {
|
|
6280
|
-
return
|
|
6929
|
+
return join5(homedir3(), ".robloxstudio-mcp", "auth-token");
|
|
6930
|
+
}
|
|
6931
|
+
var EMPTY_TOKEN_RETRY_MS = 25;
|
|
6932
|
+
var EMPTY_TOKEN_RETRIES = 20;
|
|
6933
|
+
function readTokenFile(filePath) {
|
|
6934
|
+
for (let attempt = 0; ; attempt++) {
|
|
6935
|
+
const token = readFileSync4(filePath, "utf8").trim();
|
|
6936
|
+
if (token || attempt >= EMPTY_TOKEN_RETRIES)
|
|
6937
|
+
return token;
|
|
6938
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EMPTY_TOKEN_RETRY_MS);
|
|
6939
|
+
}
|
|
6281
6940
|
}
|
|
6282
6941
|
function resolveAuthToken() {
|
|
6283
6942
|
const noAuth = (process.env.ROBLOX_STUDIO_NO_AUTH || "").toLowerCase();
|
|
@@ -6290,24 +6949,40 @@ function resolveAuthToken() {
|
|
|
6290
6949
|
}
|
|
6291
6950
|
const filePath = authTokenFilePath();
|
|
6292
6951
|
try {
|
|
6293
|
-
|
|
6294
|
-
const existing =
|
|
6295
|
-
if (existing)
|
|
6296
|
-
|
|
6297
|
-
}
|
|
6952
|
+
try {
|
|
6953
|
+
const existing = readTokenFile(filePath);
|
|
6954
|
+
if (!existing)
|
|
6955
|
+
throw new Error("Auth token file is empty; remove it while all MCP servers are stopped to reinitialize it.");
|
|
6956
|
+
return { token: existing, source: "file", filePath };
|
|
6957
|
+
} catch (error) {
|
|
6958
|
+
if (error.code !== "ENOENT")
|
|
6959
|
+
throw error;
|
|
6298
6960
|
}
|
|
6299
6961
|
const fresh = randomBytes(32).toString("hex");
|
|
6300
|
-
|
|
6301
|
-
|
|
6962
|
+
mkdirSync3(dirname2(filePath), { recursive: true, mode: 448 });
|
|
6963
|
+
const stagingPath = `${filePath}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`;
|
|
6964
|
+
writeFileSync2(stagingPath, fresh + "\n", { encoding: "utf8", mode: 384, flag: "wx" });
|
|
6302
6965
|
try {
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6966
|
+
try {
|
|
6967
|
+
linkSync2(stagingPath, filePath);
|
|
6968
|
+
} catch (error) {
|
|
6969
|
+
if (error.code !== "EEXIST")
|
|
6970
|
+
throw error;
|
|
6971
|
+
}
|
|
6972
|
+
const token = readTokenFile(filePath);
|
|
6973
|
+
if (!token)
|
|
6974
|
+
throw new Error("Auth token file is empty.");
|
|
6975
|
+
return { token, source: "file", filePath };
|
|
6976
|
+
} finally {
|
|
6977
|
+
try {
|
|
6978
|
+
unlinkSync2(stagingPath);
|
|
6979
|
+
} catch {
|
|
6980
|
+
}
|
|
6981
|
+
}
|
|
6982
|
+
} catch (err2) {
|
|
6983
|
+
console.error(`[auth] Could not read/create ${filePath} (${err2 instanceof Error ? err2.message : err2}). Using an in-memory token for this process; set ROBLOX_STUDIO_AUTH_TOKEN to share one across sessions.`);
|
|
6984
|
+
return { token: randomBytes(32).toString("hex"), source: "file" };
|
|
6985
|
+
}
|
|
6311
6986
|
}
|
|
6312
6987
|
function tokensMatch(provided, expected) {
|
|
6313
6988
|
const a = createHash2("sha256").update(provided).digest();
|
|
@@ -6655,7 +7330,8 @@ var TOOL_PROXY_ENDPOINTS = {
|
|
|
6655
7330
|
get_scene_analysis: ["/api/get-scene-analysis"],
|
|
6656
7331
|
export_rbxm: ["/api/export-rbxm"],
|
|
6657
7332
|
import_rbxm: ["/api/import-rbxm"],
|
|
6658
|
-
find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"]
|
|
7333
|
+
find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"],
|
|
7334
|
+
manage_monetization: ["/api/place-info"]
|
|
6659
7335
|
};
|
|
6660
7336
|
function toPassivePeer(peer) {
|
|
6661
7337
|
return {
|
|
@@ -6773,6 +7449,7 @@ var TOOL_HANDLERS = {
|
|
|
6773
7449
|
set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
|
|
6774
7450
|
capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
|
|
6775
7451
|
manage_instance: (tools, body) => tools.manageInstance(body),
|
|
7452
|
+
manage_monetization: (tools, body) => tools.manageMonetization(body, body.instance_id),
|
|
6776
7453
|
solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
|
|
6777
7454
|
multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
|
|
6778
7455
|
get_runtime_logs: (tools, body, context) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter, context?.signal),
|
|
@@ -6821,7 +7498,7 @@ var TOOL_HANDLERS = {
|
|
|
6821
7498
|
get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
|
|
6822
7499
|
get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
|
|
6823
7500
|
export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
|
|
6824
|
-
import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
|
|
7501
|
+
import_rbxm: (tools, body, context) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id, context?.signal),
|
|
6825
7502
|
find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
|
|
6826
7503
|
caseSensitive: body.caseSensitive,
|
|
6827
7504
|
usePattern: body.usePattern,
|
|
@@ -6990,8 +7667,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6990
7667
|
});
|
|
6991
7668
|
const authToken = security?.authToken;
|
|
6992
7669
|
const authRequired = (requestPath) => {
|
|
6993
|
-
const
|
|
6994
|
-
return
|
|
7670
|
+
const path11 = requestPath.toLowerCase().replace(/\/+$/, "");
|
|
7671
|
+
return path11 === "/mcp" || path11.startsWith("/mcp/") || path11 === "/proxy" || path11 === "/topology" || path11 === "/request-status" || path11 === "/unregister-instance-id" || path11 === "/create-multiplayer-group" || path11 === "/remove-multiplayer-group";
|
|
6995
7672
|
};
|
|
6996
7673
|
app.use((req, res, next) => {
|
|
6997
7674
|
if (!authToken || !authRequired(req.path)) {
|
|
@@ -7420,6 +8097,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
7420
8097
|
studioTransport.close();
|
|
7421
8098
|
webSocketServer.close();
|
|
7422
8099
|
await mcpHandler?.close();
|
|
8100
|
+
await tools.dispose();
|
|
7423
8101
|
};
|
|
7424
8102
|
return app;
|
|
7425
8103
|
}
|
|
@@ -7440,7 +8118,7 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
|
|
|
7440
8118
|
throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
|
|
7441
8119
|
}
|
|
7442
8120
|
function bindPort(app, host, port) {
|
|
7443
|
-
const { promise, resolve:
|
|
8121
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
7444
8122
|
const server = http.createServer(app);
|
|
7445
8123
|
const onError = (err2) => {
|
|
7446
8124
|
server.removeListener("error", onError);
|
|
@@ -7453,7 +8131,7 @@ function bindPort(app, host, port) {
|
|
|
7453
8131
|
if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
|
|
7454
8132
|
app.attachStudioTransport(server);
|
|
7455
8133
|
}
|
|
7456
|
-
|
|
8134
|
+
resolve6(server);
|
|
7457
8135
|
} catch (error) {
|
|
7458
8136
|
server.close();
|
|
7459
8137
|
reject(error);
|
|
@@ -7462,6 +8140,123 @@ function bindPort(app, host, port) {
|
|
|
7462
8140
|
return promise;
|
|
7463
8141
|
}
|
|
7464
8142
|
|
|
8143
|
+
// ../core/dist/rbxm-input.js
|
|
8144
|
+
import { open as open3 } from "fs/promises";
|
|
8145
|
+
import { resolve as resolve2 } from "path";
|
|
8146
|
+
var ENVELOPE_RESERVE_BYTES = 16 * 1024;
|
|
8147
|
+
var DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
8148
|
+
function rbxmInputByteLimit(parentPath, sourceLabel) {
|
|
8149
|
+
const metadataBytes = Buffer.byteLength(JSON.stringify({
|
|
8150
|
+
base64: "",
|
|
8151
|
+
parent_path: parentPath,
|
|
8152
|
+
source_label: sourceLabel
|
|
8153
|
+
}));
|
|
8154
|
+
const encodedBytes = Math.min(HTTP_BODY_LIMIT_BYTES, MAX_STUDIO_FRAME_BYTES) - ENVELOPE_RESERVE_BYTES - metadataBytes;
|
|
8155
|
+
if (encodedBytes < 4)
|
|
8156
|
+
throw new Error("RBXM import metadata exceeds the transport byte budget.");
|
|
8157
|
+
return Math.floor(encodedBytes / 4) * 3;
|
|
8158
|
+
}
|
|
8159
|
+
function tooLarge(limit) {
|
|
8160
|
+
return new Error(`RBXM input exceeds the ${limit}-byte limit after allowing for base64 and transport overhead.`);
|
|
8161
|
+
}
|
|
8162
|
+
async function readBoundedResponse(response, limit) {
|
|
8163
|
+
const declared = Number(response.headers.get("content-length"));
|
|
8164
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
8165
|
+
await response.body?.cancel();
|
|
8166
|
+
throw tooLarge(limit);
|
|
8167
|
+
}
|
|
8168
|
+
if (!response.body)
|
|
8169
|
+
return Buffer.alloc(0);
|
|
8170
|
+
const reader = response.body.getReader();
|
|
8171
|
+
const chunks = [];
|
|
8172
|
+
let size = 0;
|
|
8173
|
+
try {
|
|
8174
|
+
for (; ; ) {
|
|
8175
|
+
const { done, value } = await reader.read();
|
|
8176
|
+
if (done)
|
|
8177
|
+
break;
|
|
8178
|
+
size += value.byteLength;
|
|
8179
|
+
if (size > limit)
|
|
8180
|
+
throw tooLarge(limit);
|
|
8181
|
+
chunks.push(Buffer.from(value));
|
|
8182
|
+
}
|
|
8183
|
+
return Buffer.concat(chunks, size);
|
|
8184
|
+
} finally {
|
|
8185
|
+
await reader.cancel().catch(() => {
|
|
8186
|
+
});
|
|
8187
|
+
reader.releaseLock();
|
|
8188
|
+
}
|
|
8189
|
+
}
|
|
8190
|
+
async function readRbxmInput(source, parentPath, signal) {
|
|
8191
|
+
const modes = ["path", "url", "base64"];
|
|
8192
|
+
const provided = modes.filter((key) => source[key] !== void 0);
|
|
8193
|
+
if (provided.length !== 1 || typeof source[provided[0]] !== "string") {
|
|
8194
|
+
throw new Error("source must contain exactly one string: path, url, or base64");
|
|
8195
|
+
}
|
|
8196
|
+
signal?.throwIfAborted();
|
|
8197
|
+
if (source.path !== void 0) {
|
|
8198
|
+
const sourceLabel = resolve2(source.path);
|
|
8199
|
+
const limit2 = rbxmInputByteLimit(parentPath, sourceLabel);
|
|
8200
|
+
const file = await open3(sourceLabel, "r");
|
|
8201
|
+
try {
|
|
8202
|
+
const stats = await file.stat();
|
|
8203
|
+
if (!stats.isFile())
|
|
8204
|
+
throw new Error("RBXM source must be a regular file.");
|
|
8205
|
+
if (stats.size > limit2)
|
|
8206
|
+
throw tooLarge(limit2);
|
|
8207
|
+
const chunks = [];
|
|
8208
|
+
let size = 0;
|
|
8209
|
+
for (; ; ) {
|
|
8210
|
+
signal?.throwIfAborted();
|
|
8211
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit2 - size + 1));
|
|
8212
|
+
const { bytesRead } = await file.read(chunk);
|
|
8213
|
+
if (bytesRead === 0)
|
|
8214
|
+
break;
|
|
8215
|
+
size += bytesRead;
|
|
8216
|
+
if (size > limit2)
|
|
8217
|
+
throw tooLarge(limit2);
|
|
8218
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
8219
|
+
}
|
|
8220
|
+
return { bytes: Buffer.concat(chunks, size), sourceLabel };
|
|
8221
|
+
} finally {
|
|
8222
|
+
await file.close();
|
|
8223
|
+
}
|
|
8224
|
+
}
|
|
8225
|
+
if (source.url !== void 0) {
|
|
8226
|
+
const url = new URL(source.url);
|
|
8227
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
8228
|
+
throw new Error(`import_rbxm url must use http(s); got ${url.protocol}`);
|
|
8229
|
+
}
|
|
8230
|
+
const limit2 = rbxmInputByteLimit(parentPath, source.url);
|
|
8231
|
+
const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
|
|
8232
|
+
const response = await fetch(url, {
|
|
8233
|
+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline
|
|
8234
|
+
});
|
|
8235
|
+
if (!response.ok) {
|
|
8236
|
+
await response.body?.cancel();
|
|
8237
|
+
throw new Error(`RBXM download returned HTTP ${response.status}`);
|
|
8238
|
+
}
|
|
8239
|
+
return { bytes: await readBoundedResponse(response, limit2), sourceLabel: source.url };
|
|
8240
|
+
}
|
|
8241
|
+
const limit = rbxmInputByteLimit(parentPath, "base64(9999999999B)");
|
|
8242
|
+
const maxEncoded = 4 * Math.ceil(limit / 3);
|
|
8243
|
+
if (source.base64.length > maxEncoded + 2 * Math.ceil(maxEncoded / 64))
|
|
8244
|
+
throw tooLarge(limit);
|
|
8245
|
+
const encoded = source.base64.replace(/[\t\n\r ]+/g, "");
|
|
8246
|
+
if (encoded.length > maxEncoded)
|
|
8247
|
+
throw tooLarge(limit);
|
|
8248
|
+
if (encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.includes("=") && encoded.length % 4 !== 0) {
|
|
8249
|
+
throw new Error("RBXM base64 must be valid base64.");
|
|
8250
|
+
}
|
|
8251
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
8252
|
+
if (bytes.length > limit)
|
|
8253
|
+
throw tooLarge(limit);
|
|
8254
|
+
if (bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
|
|
8255
|
+
throw new Error("RBXM base64 is not canonical.");
|
|
8256
|
+
}
|
|
8257
|
+
return { bytes, sourceLabel: `base64(${bytes.length}B)` };
|
|
8258
|
+
}
|
|
8259
|
+
|
|
7465
8260
|
// ../core/dist/tools/studio-client.js
|
|
7466
8261
|
var StudioHttpClient = class {
|
|
7467
8262
|
bridge;
|
|
@@ -7474,6 +8269,56 @@ var StudioHttpClient = class {
|
|
|
7474
8269
|
};
|
|
7475
8270
|
|
|
7476
8271
|
// ../core/dist/opencloud-client.js
|
|
8272
|
+
var MONETIZATION_APIS = {
|
|
8273
|
+
developer_product: {
|
|
8274
|
+
label: "developer product",
|
|
8275
|
+
collectionPath: (universeId) => `/developer-products/v2/universes/${universeId}/developer-products`,
|
|
8276
|
+
listField: "developerProducts",
|
|
8277
|
+
idField: "productId",
|
|
8278
|
+
iconField: "iconImageAssetId"
|
|
8279
|
+
},
|
|
8280
|
+
game_pass: {
|
|
8281
|
+
label: "game pass",
|
|
8282
|
+
collectionPath: (universeId) => `/game-passes/v1/universes/${universeId}/game-passes`,
|
|
8283
|
+
listField: "gamePasses",
|
|
8284
|
+
idField: "gamePassId",
|
|
8285
|
+
iconField: "iconAssetId"
|
|
8286
|
+
}
|
|
8287
|
+
};
|
|
8288
|
+
function readField(source, key) {
|
|
8289
|
+
return typeof source === "object" && source !== null ? Reflect.get(source, key) : void 0;
|
|
8290
|
+
}
|
|
8291
|
+
function positiveSafeInteger(value) {
|
|
8292
|
+
const parsed = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
|
|
8293
|
+
return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
8294
|
+
}
|
|
8295
|
+
function normalizeMonetizationItem(kind, universeId, value) {
|
|
8296
|
+
const api = MONETIZATION_APIS[kind];
|
|
8297
|
+
const id = positiveSafeInteger(readField(value, api.idField));
|
|
8298
|
+
if (id === void 0) {
|
|
8299
|
+
throw new Error(`Open Cloud returned a ${api.label} without a valid ${api.idField}.`);
|
|
8300
|
+
}
|
|
8301
|
+
const name = readField(value, "name");
|
|
8302
|
+
const description = readField(value, "description");
|
|
8303
|
+
const price = readField(readField(value, "priceInformation"), "defaultPriceInRobux");
|
|
8304
|
+
const createdAt = readField(value, "createdTimestamp");
|
|
8305
|
+
const updatedAt = readField(value, "updatedTimestamp");
|
|
8306
|
+
const immutable = readField(value, "isImmutable");
|
|
8307
|
+
return {
|
|
8308
|
+
kind,
|
|
8309
|
+
id,
|
|
8310
|
+
universeId: positiveSafeInteger(readField(value, "universeId")) ?? universeId,
|
|
8311
|
+
name: typeof name === "string" ? name : "",
|
|
8312
|
+
description: typeof description === "string" ? description : "",
|
|
8313
|
+
forSale: readField(value, "isForSale") === true,
|
|
8314
|
+
price: typeof price === "number" && Number.isSafeInteger(price) && price >= 0 ? price : null,
|
|
8315
|
+
managedPricing: readField(value, "isManagedPricingEnabled") === true,
|
|
8316
|
+
iconAssetId: positiveSafeInteger(readField(value, api.iconField)) ?? null,
|
|
8317
|
+
...typeof createdAt === "string" ? { createdAt } : {},
|
|
8318
|
+
...typeof updatedAt === "string" ? { updatedAt } : {},
|
|
8319
|
+
...kind === "developer_product" && typeof immutable === "boolean" ? { immutable } : {}
|
|
8320
|
+
};
|
|
8321
|
+
}
|
|
7477
8322
|
function detectAudioMimeType(data) {
|
|
7478
8323
|
if (data.length >= 4 && data.subarray(0, 4).toString("ascii") === "OggS") {
|
|
7479
8324
|
return "audio/ogg";
|
|
@@ -7489,6 +8334,68 @@ function detectAudioMimeType(data) {
|
|
|
7489
8334
|
}
|
|
7490
8335
|
return void 0;
|
|
7491
8336
|
}
|
|
8337
|
+
var OpenCloudRequestError = class extends Error {
|
|
8338
|
+
status;
|
|
8339
|
+
details;
|
|
8340
|
+
constructor(message, status, details = {}) {
|
|
8341
|
+
super(message);
|
|
8342
|
+
this.status = status;
|
|
8343
|
+
this.details = details;
|
|
8344
|
+
this.name = "OpenCloudRequestError";
|
|
8345
|
+
}
|
|
8346
|
+
};
|
|
8347
|
+
async function openCloudResponseError(response) {
|
|
8348
|
+
const errorBody = await response.text();
|
|
8349
|
+
let parsed;
|
|
8350
|
+
try {
|
|
8351
|
+
parsed = JSON.parse(errorBody);
|
|
8352
|
+
} catch {
|
|
8353
|
+
parsed = void 0;
|
|
8354
|
+
}
|
|
8355
|
+
const text = (key) => {
|
|
8356
|
+
const value = readField(parsed, key);
|
|
8357
|
+
return typeof value === "string" && value ? value : void 0;
|
|
8358
|
+
};
|
|
8359
|
+
const errorMessage2 = text("detail") ?? text("message") ?? text("errorMessage") ?? errorBody;
|
|
8360
|
+
const errorCode3 = readField(parsed, "errorCode");
|
|
8361
|
+
const field = text("field");
|
|
8362
|
+
const hint = text("hint");
|
|
8363
|
+
const details = {
|
|
8364
|
+
...typeof errorCode3 === "string" || typeof errorCode3 === "number" ? { errorCode: errorCode3 } : {},
|
|
8365
|
+
...field ? { field } : {},
|
|
8366
|
+
...hint ? { hint } : {}
|
|
8367
|
+
};
|
|
8368
|
+
const { status } = response;
|
|
8369
|
+
let message;
|
|
8370
|
+
if (status === 401) {
|
|
8371
|
+
message = "Invalid or expired API key";
|
|
8372
|
+
} else if (status === 403) {
|
|
8373
|
+
message = `API key lacks required permissions: ${errorMessage2}`;
|
|
8374
|
+
} else if (status === 429) {
|
|
8375
|
+
message = "Rate limit exceeded. Please try again later.";
|
|
8376
|
+
} else {
|
|
8377
|
+
message = `Open Cloud API error (${status}): ${errorMessage2}`;
|
|
8378
|
+
}
|
|
8379
|
+
return new OpenCloudRequestError(message, status, details);
|
|
8380
|
+
}
|
|
8381
|
+
function monetizationForm(changes) {
|
|
8382
|
+
const form = new FormData();
|
|
8383
|
+
if (changes.name !== void 0)
|
|
8384
|
+
form.append("name", changes.name);
|
|
8385
|
+
if (changes.description !== void 0)
|
|
8386
|
+
form.append("description", changes.description);
|
|
8387
|
+
if (changes.price !== void 0)
|
|
8388
|
+
form.append("price", String(changes.price));
|
|
8389
|
+
if (changes.forSale !== void 0)
|
|
8390
|
+
form.append("isForSale", String(changes.forSale));
|
|
8391
|
+
if (changes.managedPricing !== void 0) {
|
|
8392
|
+
form.append("isManagedPricingEnabled", String(changes.managedPricing));
|
|
8393
|
+
}
|
|
8394
|
+
if (changes.icon) {
|
|
8395
|
+
form.append("imageFile", new Blob([new Uint8Array(changes.icon.data)], { type: changes.icon.mimeType }), changes.icon.fileName);
|
|
8396
|
+
}
|
|
8397
|
+
return form;
|
|
8398
|
+
}
|
|
7492
8399
|
var OpenCloudClient = class {
|
|
7493
8400
|
apiKey;
|
|
7494
8401
|
baseUrl;
|
|
@@ -7529,29 +8436,12 @@ var OpenCloudClient = class {
|
|
|
7529
8436
|
body: body ? JSON.stringify(body) : void 0,
|
|
7530
8437
|
signal: controller.signal
|
|
7531
8438
|
});
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
const errorBody = await response.text();
|
|
7535
|
-
let errorMessage2;
|
|
7536
|
-
try {
|
|
7537
|
-
const errorJson = JSON.parse(errorBody);
|
|
7538
|
-
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
7539
|
-
} catch {
|
|
7540
|
-
errorMessage2 = errorBody;
|
|
7541
|
-
}
|
|
7542
|
-
if (response.status === 401) {
|
|
7543
|
-
throw new Error("Invalid or expired API key");
|
|
7544
|
-
} else if (response.status === 403) {
|
|
7545
|
-
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
7546
|
-
} else if (response.status === 429) {
|
|
7547
|
-
throw new Error("Rate limit exceeded. Please try again later.");
|
|
7548
|
-
} else {
|
|
7549
|
-
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
7550
|
-
}
|
|
7551
|
-
}
|
|
8439
|
+
if (!response.ok)
|
|
8440
|
+
throw await openCloudResponseError(response);
|
|
7552
8441
|
return await response.json();
|
|
7553
8442
|
} catch (error) {
|
|
7554
|
-
|
|
8443
|
+
if (controller.signal.aborted)
|
|
8444
|
+
throw new Error("Request timed out");
|
|
7555
8445
|
if (error instanceof Error) {
|
|
7556
8446
|
if (error.name === "AbortError") {
|
|
7557
8447
|
throw new Error("Request timed out");
|
|
@@ -7559,6 +8449,8 @@ var OpenCloudClient = class {
|
|
|
7559
8449
|
throw error;
|
|
7560
8450
|
}
|
|
7561
8451
|
throw new Error(`Unknown error: ${String(error)}`);
|
|
8452
|
+
} finally {
|
|
8453
|
+
clearTimeout(timeoutId);
|
|
7562
8454
|
}
|
|
7563
8455
|
}
|
|
7564
8456
|
async searchAssets(params) {
|
|
@@ -7590,10 +8482,41 @@ var OpenCloudClient = class {
|
|
|
7590
8482
|
}
|
|
7591
8483
|
});
|
|
7592
8484
|
}
|
|
8485
|
+
async listMonetizationItems(kind, universeId, pageToken) {
|
|
8486
|
+
const api = MONETIZATION_APIS[kind];
|
|
8487
|
+
const response = await this.request(`${api.collectionPath(universeId)}/creator`, {
|
|
8488
|
+
params: { pageToken }
|
|
8489
|
+
});
|
|
8490
|
+
const rows = readField(response, api.listField);
|
|
8491
|
+
if (!Array.isArray(rows)) {
|
|
8492
|
+
throw new Error(`Open Cloud returned a ${api.label} list without ${api.listField}.`);
|
|
8493
|
+
}
|
|
8494
|
+
const nextPageToken = readField(response, "nextPageToken");
|
|
8495
|
+
return {
|
|
8496
|
+
items: rows.map((row) => normalizeMonetizationItem(kind, universeId, row)),
|
|
8497
|
+
...typeof nextPageToken === "string" && nextPageToken ? { nextPageToken } : {}
|
|
8498
|
+
};
|
|
8499
|
+
}
|
|
8500
|
+
async getMonetizationItem(kind, universeId, id) {
|
|
8501
|
+
const path11 = `${MONETIZATION_APIS[kind].collectionPath(universeId)}/${id}/creator`;
|
|
8502
|
+
return normalizeMonetizationItem(kind, universeId, await this.request(path11));
|
|
8503
|
+
}
|
|
8504
|
+
async createMonetizationItem(kind, universeId, item) {
|
|
8505
|
+
const response = await this.requestMultipart(MONETIZATION_APIS[kind].collectionPath(universeId), monetizationForm(item));
|
|
8506
|
+
return normalizeMonetizationItem(kind, universeId, response);
|
|
8507
|
+
}
|
|
8508
|
+
/** Changes only the given fields. Roblox answers with an empty 204. */
|
|
8509
|
+
async updateMonetizationItem(kind, universeId, id, changes) {
|
|
8510
|
+
const path11 = `${MONETIZATION_APIS[kind].collectionPath(universeId)}/${id}`;
|
|
8511
|
+
await this.sendMultipart(path11, monetizationForm(changes), "PATCH", async (response) => {
|
|
8512
|
+
await response.arrayBuffer();
|
|
8513
|
+
});
|
|
8514
|
+
}
|
|
7593
8515
|
async getAssetThumbnail(assetId, size = "420x420") {
|
|
7594
8516
|
const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
|
|
7595
8517
|
try {
|
|
7596
|
-
const
|
|
8518
|
+
const signal = AbortSignal.timeout(this.timeout);
|
|
8519
|
+
const response = await fetch(url, { signal });
|
|
7597
8520
|
if (!response.ok)
|
|
7598
8521
|
return null;
|
|
7599
8522
|
const data = await response.json();
|
|
@@ -7601,7 +8524,7 @@ var OpenCloudClient = class {
|
|
|
7601
8524
|
if (!thumbnail || thumbnail.state !== "Completed" || !thumbnail.imageUrl) {
|
|
7602
8525
|
return null;
|
|
7603
8526
|
}
|
|
7604
|
-
const imageResponse = await fetch(thumbnail.imageUrl);
|
|
8527
|
+
const imageResponse = await fetch(thumbnail.imageUrl, { signal });
|
|
7605
8528
|
if (!imageResponse.ok)
|
|
7606
8529
|
return null;
|
|
7607
8530
|
const arrayBuffer = await imageResponse.arrayBuffer();
|
|
@@ -7622,7 +8545,7 @@ var OpenCloudClient = class {
|
|
|
7622
8545
|
for (const batch of batches) {
|
|
7623
8546
|
const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${batch.join(",")}&size=${size}&format=Png`;
|
|
7624
8547
|
try {
|
|
7625
|
-
const response = await fetch(url);
|
|
8548
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(this.timeout) });
|
|
7626
8549
|
if (response.ok) {
|
|
7627
8550
|
const data = await response.json();
|
|
7628
8551
|
for (const thumbnail of data.data) {
|
|
@@ -7638,7 +8561,7 @@ var OpenCloudClient = class {
|
|
|
7638
8561
|
}
|
|
7639
8562
|
async downloadAudioAssetContent(assetId, maxBytes) {
|
|
7640
8563
|
if (!this.apiKey) {
|
|
7641
|
-
throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY with asset:
|
|
8564
|
+
throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY with the legacy-asset:manage scope to download audio previews.");
|
|
7642
8565
|
}
|
|
7643
8566
|
if (!Number.isSafeInteger(assetId) || assetId <= 0) {
|
|
7644
8567
|
throw new Error("Audio asset ID must be a positive integer.");
|
|
@@ -7656,7 +8579,8 @@ var OpenCloudClient = class {
|
|
|
7656
8579
|
signal: controller.signal
|
|
7657
8580
|
});
|
|
7658
8581
|
if (!deliveryResponse.ok) {
|
|
7659
|
-
|
|
8582
|
+
const scopeHint = deliveryResponse.status === 403 ? " The Open Cloud API key lacks the legacy-asset:manage scope required to download audio." : "";
|
|
8583
|
+
throw new Error(`Roblox asset delivery request failed (${deliveryResponse.status}).${scopeHint}`);
|
|
7660
8584
|
}
|
|
7661
8585
|
const delivery = await deliveryResponse.json();
|
|
7662
8586
|
if (!delivery.location) {
|
|
@@ -7683,7 +8607,7 @@ var OpenCloudClient = class {
|
|
|
7683
8607
|
const chunks = [];
|
|
7684
8608
|
let totalBytes = 0;
|
|
7685
8609
|
const reader = contentResponse.body.getReader();
|
|
7686
|
-
|
|
8610
|
+
for (; ; ) {
|
|
7687
8611
|
const { done, value } = await reader.read();
|
|
7688
8612
|
if (done)
|
|
7689
8613
|
break;
|
|
@@ -7750,7 +8674,11 @@ var OpenCloudClient = class {
|
|
|
7750
8674
|
}
|
|
7751
8675
|
return mimeTypes[ext];
|
|
7752
8676
|
}
|
|
7753
|
-
|
|
8677
|
+
requestMultipart(endpoint, formData) {
|
|
8678
|
+
return this.sendMultipart(endpoint, formData, "POST", async (response) => await response.json());
|
|
8679
|
+
}
|
|
8680
|
+
// readResponse runs inside the deadline, so a stalled body still times out.
|
|
8681
|
+
async sendMultipart(endpoint, formData, method, readResponse) {
|
|
7754
8682
|
if (!this.apiKey) {
|
|
7755
8683
|
throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY environment variable.");
|
|
7756
8684
|
}
|
|
@@ -7759,34 +8687,17 @@ var OpenCloudClient = class {
|
|
|
7759
8687
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
7760
8688
|
try {
|
|
7761
8689
|
const response = await fetch(url, {
|
|
7762
|
-
method
|
|
8690
|
+
method,
|
|
7763
8691
|
headers: { "x-api-key": this.apiKey },
|
|
7764
8692
|
body: formData,
|
|
7765
8693
|
signal: controller.signal
|
|
7766
8694
|
});
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
let errorMessage2;
|
|
7771
|
-
try {
|
|
7772
|
-
const errorJson = JSON.parse(errorBody);
|
|
7773
|
-
errorMessage2 = errorJson.detail || errorJson.message || errorBody;
|
|
7774
|
-
} catch {
|
|
7775
|
-
errorMessage2 = errorBody;
|
|
7776
|
-
}
|
|
7777
|
-
if (response.status === 401) {
|
|
7778
|
-
throw new Error("Invalid or expired API key");
|
|
7779
|
-
} else if (response.status === 403) {
|
|
7780
|
-
throw new Error(`API key lacks required permissions: ${errorMessage2}`);
|
|
7781
|
-
} else if (response.status === 429) {
|
|
7782
|
-
throw new Error("Rate limit exceeded. Please try again later.");
|
|
7783
|
-
} else {
|
|
7784
|
-
throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
|
|
7785
|
-
}
|
|
7786
|
-
}
|
|
7787
|
-
return await response.json();
|
|
8695
|
+
if (!response.ok)
|
|
8696
|
+
throw await openCloudResponseError(response);
|
|
8697
|
+
return await readResponse(response);
|
|
7788
8698
|
} catch (error) {
|
|
7789
|
-
|
|
8699
|
+
if (controller.signal.aborted)
|
|
8700
|
+
throw new Error("Request timed out");
|
|
7790
8701
|
if (error instanceof Error) {
|
|
7791
8702
|
if (error.name === "AbortError") {
|
|
7792
8703
|
throw new Error("Request timed out");
|
|
@@ -7794,6 +8705,8 @@ var OpenCloudClient = class {
|
|
|
7794
8705
|
throw error;
|
|
7795
8706
|
}
|
|
7796
8707
|
throw new Error(`Unknown error: ${String(error)}`);
|
|
8708
|
+
} finally {
|
|
8709
|
+
clearTimeout(timeoutId);
|
|
7797
8710
|
}
|
|
7798
8711
|
}
|
|
7799
8712
|
async pollOperation(operationPath, maxAttempts = 30, intervalMs = 2e3) {
|
|
@@ -7805,7 +8718,7 @@ var OpenCloudClient = class {
|
|
|
7805
8718
|
if (result.error) {
|
|
7806
8719
|
throw new Error(`Asset upload failed: ${result.error.message}`);
|
|
7807
8720
|
}
|
|
7808
|
-
await new Promise((
|
|
8721
|
+
await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
|
|
7809
8722
|
}
|
|
7810
8723
|
throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
|
|
7811
8724
|
}
|
|
@@ -7822,6 +8735,7 @@ var RobloxCookieClient = class {
|
|
|
7822
8735
|
return !!this.cookie;
|
|
7823
8736
|
}
|
|
7824
8737
|
async fetchWithCsrf(url, options = {}) {
|
|
8738
|
+
options = { ...options, signal: options.signal ?? AbortSignal.timeout(3e4) };
|
|
7825
8739
|
const headers = {
|
|
7826
8740
|
Cookie: `.ROBLOSECURITY=${this.cookie}`,
|
|
7827
8741
|
...options.headers || {}
|
|
@@ -7835,6 +8749,7 @@ var RobloxCookieClient = class {
|
|
|
7835
8749
|
if (newToken) {
|
|
7836
8750
|
this.csrfToken = newToken;
|
|
7837
8751
|
headers["X-CSRF-TOKEN"] = newToken;
|
|
8752
|
+
await response.body?.cancel();
|
|
7838
8753
|
return fetch(url, { ...options, headers });
|
|
7839
8754
|
}
|
|
7840
8755
|
}
|
|
@@ -7954,7 +8869,7 @@ var RobloxCookieClient = class {
|
|
|
7954
8869
|
throw new Error("Image upload completed without an asset ID.");
|
|
7955
8870
|
}
|
|
7956
8871
|
if (attempt < maxAttempts - 1) {
|
|
7957
|
-
await new Promise((
|
|
8872
|
+
await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
|
|
7958
8873
|
}
|
|
7959
8874
|
}
|
|
7960
8875
|
throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
|
|
@@ -7977,9 +8892,54 @@ var RobloxCookieClient = class {
|
|
|
7977
8892
|
};
|
|
7978
8893
|
|
|
7979
8894
|
// ../core/dist/image-decode.js
|
|
7980
|
-
import * as fs2 from "fs";
|
|
7981
|
-
import * as path3 from "path";
|
|
7982
8895
|
import { inflateSync } from "zlib";
|
|
8896
|
+
|
|
8897
|
+
// ../core/dist/local-file.js
|
|
8898
|
+
import * as fs2 from "fs";
|
|
8899
|
+
import * as path6 from "path";
|
|
8900
|
+
function errorCode(error) {
|
|
8901
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
8902
|
+
}
|
|
8903
|
+
function readRegularFileWithinLimit(filePath, argumentName, validateSize) {
|
|
8904
|
+
let descriptor;
|
|
8905
|
+
try {
|
|
8906
|
+
const flags = fs2.constants.O_RDONLY | (fs2.constants.O_NONBLOCK ?? 0);
|
|
8907
|
+
descriptor = fs2.openSync(path6.resolve(filePath), flags);
|
|
8908
|
+
} catch (error) {
|
|
8909
|
+
const code = errorCode(error);
|
|
8910
|
+
if (code === "ENOENT") {
|
|
8911
|
+
throw new Error(`${argumentName} not found: ${filePath}`);
|
|
8912
|
+
}
|
|
8913
|
+
if (code === "EISDIR") {
|
|
8914
|
+
throw new Error(`${argumentName} must reference a regular file: ${filePath}`);
|
|
8915
|
+
}
|
|
8916
|
+
throw error;
|
|
8917
|
+
}
|
|
8918
|
+
try {
|
|
8919
|
+
const stats = fs2.fstatSync(descriptor);
|
|
8920
|
+
if (!stats.isFile()) {
|
|
8921
|
+
throw new Error(`${argumentName} must reference a regular file: ${filePath}`);
|
|
8922
|
+
}
|
|
8923
|
+
validateSize(stats.size);
|
|
8924
|
+
const data = Buffer.allocUnsafe(stats.size + 1);
|
|
8925
|
+
let offset = 0;
|
|
8926
|
+
while (offset < data.length) {
|
|
8927
|
+
const bytesRead = fs2.readSync(descriptor, data, offset, data.length - offset, offset);
|
|
8928
|
+
if (bytesRead === 0)
|
|
8929
|
+
break;
|
|
8930
|
+
offset += bytesRead;
|
|
8931
|
+
}
|
|
8932
|
+
if (offset !== stats.size) {
|
|
8933
|
+
validateSize(offset);
|
|
8934
|
+
throw new Error(`${argumentName} size changed while reading: ${filePath}`);
|
|
8935
|
+
}
|
|
8936
|
+
return data.subarray(0, offset);
|
|
8937
|
+
} finally {
|
|
8938
|
+
fs2.closeSync(descriptor);
|
|
8939
|
+
}
|
|
8940
|
+
}
|
|
8941
|
+
|
|
8942
|
+
// ../core/dist/image-decode.js
|
|
7983
8943
|
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
7984
8944
|
var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
|
|
7985
8945
|
var MAX_PNG_DIMENSION = 16 * 1024;
|
|
@@ -8043,40 +9003,6 @@ function getBase64DecodedLength(encoded) {
|
|
|
8043
9003
|
}
|
|
8044
9004
|
return decodedLength;
|
|
8045
9005
|
}
|
|
8046
|
-
function readPngFileWithinLimit(resolved, imagePath) {
|
|
8047
|
-
let descriptor;
|
|
8048
|
-
try {
|
|
8049
|
-
const flags = fs2.constants.O_RDONLY | (fs2.constants.O_NONBLOCK ?? 0);
|
|
8050
|
-
descriptor = fs2.openSync(resolved, flags);
|
|
8051
|
-
} catch (error) {
|
|
8052
|
-
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
8053
|
-
throw new Error(`image_path not found: ${imagePath}`);
|
|
8054
|
-
}
|
|
8055
|
-
throw error;
|
|
8056
|
-
}
|
|
8057
|
-
try {
|
|
8058
|
-
const stats = fs2.fstatSync(descriptor);
|
|
8059
|
-
if (!stats.isFile()) {
|
|
8060
|
-
throw new Error(`image_path must reference a regular file: ${imagePath}`);
|
|
8061
|
-
}
|
|
8062
|
-
validatePngInputSize(stats.size);
|
|
8063
|
-
const data = Buffer.allocUnsafe(stats.size + 1);
|
|
8064
|
-
let offset = 0;
|
|
8065
|
-
while (offset < data.length) {
|
|
8066
|
-
const bytesRead = fs2.readSync(descriptor, data, offset, data.length - offset, offset);
|
|
8067
|
-
if (bytesRead === 0)
|
|
8068
|
-
break;
|
|
8069
|
-
offset += bytesRead;
|
|
8070
|
-
}
|
|
8071
|
-
if (offset !== stats.size) {
|
|
8072
|
-
validatePngInputSize(offset);
|
|
8073
|
-
throw new Error(`image_path size changed while reading: ${imagePath}`);
|
|
8074
|
-
}
|
|
8075
|
-
return data.subarray(0, offset);
|
|
8076
|
-
} finally {
|
|
8077
|
-
fs2.closeSync(descriptor);
|
|
8078
|
-
}
|
|
8079
|
-
}
|
|
8080
9006
|
function validateSourceDimensions(width, height, maxPixels) {
|
|
8081
9007
|
if (width <= 0 || height <= 0) {
|
|
8082
9008
|
throw new Error("PNG IHDR dimensions must be positive.");
|
|
@@ -8310,14 +9236,13 @@ function decodePngBase64ToRgba(encoded) {
|
|
|
8310
9236
|
return decodePngToRgba(data);
|
|
8311
9237
|
}
|
|
8312
9238
|
function decodeImagePathToRgba(imagePath) {
|
|
8313
|
-
|
|
8314
|
-
return decodePngToRgba(readPngFileWithinLimit(resolved, imagePath));
|
|
9239
|
+
return decodePngToRgba(readRegularFileWithinLimit(imagePath, "image_path", validatePngInputSize));
|
|
8315
9240
|
}
|
|
8316
9241
|
|
|
8317
9242
|
// ../core/dist/studio-skills.js
|
|
8318
9243
|
import { createHash as createHash3 } from "crypto";
|
|
8319
|
-
import { existsSync as
|
|
8320
|
-
import * as
|
|
9244
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
|
|
9245
|
+
import * as path7 from "path";
|
|
8321
9246
|
|
|
8322
9247
|
// ../../node_modules/fzstd/esm/index.mjs
|
|
8323
9248
|
var ab = ArrayBuffer;
|
|
@@ -9205,17 +10130,17 @@ function parseBuiltInStudioSkills(buffer) {
|
|
|
9205
10130
|
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
9206
10131
|
}
|
|
9207
10132
|
function discoverNamedFile(root, fileName, depth = 0) {
|
|
9208
|
-
if (depth > MAX_DISCOVERY_DEPTH || !
|
|
10133
|
+
if (depth > MAX_DISCOVERY_DEPTH || !existsSync3(root))
|
|
9209
10134
|
return [];
|
|
9210
10135
|
const matches = [];
|
|
9211
10136
|
let entries;
|
|
9212
10137
|
try {
|
|
9213
|
-
entries =
|
|
10138
|
+
entries = readdirSync3(root, { withFileTypes: true });
|
|
9214
10139
|
} catch {
|
|
9215
10140
|
return matches;
|
|
9216
10141
|
}
|
|
9217
10142
|
for (const entry of entries) {
|
|
9218
|
-
const entryPath =
|
|
10143
|
+
const entryPath = path7.join(root, entry.name);
|
|
9219
10144
|
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
|
9220
10145
|
matches.push(entryPath);
|
|
9221
10146
|
} else if (entry.isDirectory()) {
|
|
@@ -9227,26 +10152,26 @@ function discoverNamedFile(root, fileName, depth = 0) {
|
|
|
9227
10152
|
function resolveAssistantBundlePath(studioExe) {
|
|
9228
10153
|
const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
|
|
9229
10154
|
if (override) {
|
|
9230
|
-
if (!
|
|
10155
|
+
if (!existsSync3(override)) {
|
|
9231
10156
|
throw new Error(`ROBLOX_STUDIO_ASSISTANT_BUNDLE does not exist: ${override}`);
|
|
9232
10157
|
}
|
|
9233
10158
|
return override;
|
|
9234
10159
|
}
|
|
9235
|
-
const exeDirectory =
|
|
10160
|
+
const exeDirectory = path7.dirname(studioExe ?? resolveStudioExe());
|
|
9236
10161
|
const roots = [
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
10162
|
+
path7.join(exeDirectory, "BuiltInStandalonePlugins"),
|
|
10163
|
+
path7.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
|
|
10164
|
+
path7.resolve(exeDirectory, "..", "..", "OTAPlugins")
|
|
9240
10165
|
];
|
|
9241
|
-
const direct =
|
|
10166
|
+
const direct = path7.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
|
|
9242
10167
|
const candidates = /* @__PURE__ */ new Set();
|
|
9243
|
-
if (
|
|
10168
|
+
if (existsSync3(direct))
|
|
9244
10169
|
candidates.add(direct);
|
|
9245
10170
|
for (const root of roots) {
|
|
9246
10171
|
for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
|
|
9247
10172
|
candidates.add(candidate);
|
|
9248
10173
|
}
|
|
9249
|
-
const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt:
|
|
10174
|
+
const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync3(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
|
|
9250
10175
|
if (!newest) {
|
|
9251
10176
|
throw new Error(`Studio Assistant bundle not found for ${studioExe}. Set ROBLOX_STUDIO_ASSISTANT_BUNDLE to the installed Assistant.rbxm path.`);
|
|
9252
10177
|
}
|
|
@@ -9254,16 +10179,16 @@ function resolveAssistantBundlePath(studioExe) {
|
|
|
9254
10179
|
}
|
|
9255
10180
|
var cachedBundle;
|
|
9256
10181
|
function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
|
|
9257
|
-
const stats =
|
|
10182
|
+
const stats = statSync3(bundlePath);
|
|
9258
10183
|
if (cachedBundle?.path === bundlePath && cachedBundle.modifiedAt === stats.mtimeMs && cachedBundle.size === stats.size) {
|
|
9259
10184
|
return cachedBundle.value;
|
|
9260
10185
|
}
|
|
9261
|
-
const buffer =
|
|
10186
|
+
const buffer = readFileSync5(bundlePath);
|
|
9262
10187
|
const skills = parseBuiltInStudioSkills(buffer);
|
|
9263
10188
|
if (skills.length === 0) {
|
|
9264
10189
|
throw new Error(`No built-in skill documents found in ${bundlePath}`);
|
|
9265
10190
|
}
|
|
9266
|
-
const studioVersion = bundlePath.split(
|
|
10191
|
+
const studioVersion = bundlePath.split(path7.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
|
|
9267
10192
|
const value = {
|
|
9268
10193
|
bundlePath,
|
|
9269
10194
|
bundleModifiedAt: stats.mtime.toISOString(),
|
|
@@ -9279,6 +10204,314 @@ function findBuiltInStudioSkill(bundle, requestedName) {
|
|
|
9279
10204
|
return bundle.skills.find((skill) => skill.name.toLowerCase() === wanted || skill.sourceName.toLowerCase() === wanted);
|
|
9280
10205
|
}
|
|
9281
10206
|
|
|
10207
|
+
// ../core/dist/tools/monetization.js
|
|
10208
|
+
var MAX_PRICE_ROBUX = 1e9;
|
|
10209
|
+
var MAX_NAME_SCAN_PAGES = 200;
|
|
10210
|
+
var MAX_ICON_BYTES = 10 * 1024 * 1024;
|
|
10211
|
+
var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
10212
|
+
var SCOPE_PREFIX = {
|
|
10213
|
+
developer_product: "developer-product",
|
|
10214
|
+
game_pass: "game-pass"
|
|
10215
|
+
};
|
|
10216
|
+
var ACTION_FIELDS = {
|
|
10217
|
+
list: ["page_token"],
|
|
10218
|
+
get: ["id"],
|
|
10219
|
+
create: ["name", "description", "price", "for_sale", "managed_pricing", "image_path"],
|
|
10220
|
+
update: ["id", "name", "description", "price", "for_sale", "managed_pricing", "image_path"]
|
|
10221
|
+
};
|
|
10222
|
+
var ACTION_SCOPED_FIELDS = [
|
|
10223
|
+
"id",
|
|
10224
|
+
"page_token",
|
|
10225
|
+
"name",
|
|
10226
|
+
"description",
|
|
10227
|
+
"price",
|
|
10228
|
+
"for_sale",
|
|
10229
|
+
"managed_pricing",
|
|
10230
|
+
"image_path"
|
|
10231
|
+
];
|
|
10232
|
+
var MonetizationError = class extends Error {
|
|
10233
|
+
code;
|
|
10234
|
+
details;
|
|
10235
|
+
constructor(code, message, details = {}) {
|
|
10236
|
+
super(message);
|
|
10237
|
+
this.code = code;
|
|
10238
|
+
this.details = details;
|
|
10239
|
+
this.name = "MonetizationError";
|
|
10240
|
+
}
|
|
10241
|
+
};
|
|
10242
|
+
function invalidArgument(message) {
|
|
10243
|
+
return new MonetizationError("invalid_argument", message);
|
|
10244
|
+
}
|
|
10245
|
+
function isAction(value) {
|
|
10246
|
+
return value === "list" || value === "get" || value === "create" || value === "update";
|
|
10247
|
+
}
|
|
10248
|
+
function isKind(value) {
|
|
10249
|
+
return value === "developer_product" || value === "game_pass";
|
|
10250
|
+
}
|
|
10251
|
+
function isPresent(value) {
|
|
10252
|
+
return value !== void 0 && value !== null;
|
|
10253
|
+
}
|
|
10254
|
+
function optionalId(value, name) {
|
|
10255
|
+
if (!isPresent(value))
|
|
10256
|
+
return void 0;
|
|
10257
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
10258
|
+
throw invalidArgument(`${name} must be a positive whole number.`);
|
|
10259
|
+
}
|
|
10260
|
+
return value;
|
|
10261
|
+
}
|
|
10262
|
+
function optionalPrice(value) {
|
|
10263
|
+
if (!isPresent(value))
|
|
10264
|
+
return void 0;
|
|
10265
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > MAX_PRICE_ROBUX) {
|
|
10266
|
+
throw invalidArgument(`price must be a whole number of Robux from 1 to ${MAX_PRICE_ROBUX}.`);
|
|
10267
|
+
}
|
|
10268
|
+
return value;
|
|
10269
|
+
}
|
|
10270
|
+
function optionalString(value, name) {
|
|
10271
|
+
if (!isPresent(value))
|
|
10272
|
+
return void 0;
|
|
10273
|
+
if (typeof value !== "string")
|
|
10274
|
+
throw invalidArgument(`${name} must be a string.`);
|
|
10275
|
+
return value;
|
|
10276
|
+
}
|
|
10277
|
+
function optionalNonBlankString(value, name) {
|
|
10278
|
+
const text = optionalString(value, name);
|
|
10279
|
+
if (text !== void 0 && text.trim() === "")
|
|
10280
|
+
throw invalidArgument(`${name} must not be empty.`);
|
|
10281
|
+
return text;
|
|
10282
|
+
}
|
|
10283
|
+
function optionalBoolean(value, name) {
|
|
10284
|
+
if (!isPresent(value))
|
|
10285
|
+
return void 0;
|
|
10286
|
+
if (typeof value !== "boolean")
|
|
10287
|
+
throw invalidArgument(`${name} must be true or false.`);
|
|
10288
|
+
return value;
|
|
10289
|
+
}
|
|
10290
|
+
function parseRequest(request) {
|
|
10291
|
+
const { action, kind } = request;
|
|
10292
|
+
if (!isAction(action))
|
|
10293
|
+
throw invalidArgument("action must be list, get, create, or update.");
|
|
10294
|
+
if (!isKind(kind))
|
|
10295
|
+
throw invalidArgument("kind must be developer_product or game_pass.");
|
|
10296
|
+
for (const field of ACTION_SCOPED_FIELDS) {
|
|
10297
|
+
if (isPresent(request[field]) && !ACTION_FIELDS[action].includes(field)) {
|
|
10298
|
+
throw invalidArgument(`${field} is not used by action=${action}.`);
|
|
10299
|
+
}
|
|
10300
|
+
}
|
|
10301
|
+
const parsed = {
|
|
10302
|
+
action,
|
|
10303
|
+
kind,
|
|
10304
|
+
universeId: optionalId(request.universe_id, "universe_id"),
|
|
10305
|
+
id: optionalId(request.id, "id"),
|
|
10306
|
+
pageToken: optionalNonBlankString(request.page_token, "page_token"),
|
|
10307
|
+
name: optionalNonBlankString(request.name, "name")?.trim(),
|
|
10308
|
+
description: optionalString(request.description, "description"),
|
|
10309
|
+
price: optionalPrice(request.price),
|
|
10310
|
+
forSale: optionalBoolean(request.for_sale, "for_sale"),
|
|
10311
|
+
managedPricing: optionalBoolean(request.managed_pricing, "managed_pricing"),
|
|
10312
|
+
imagePath: optionalNonBlankString(request.image_path, "image_path")
|
|
10313
|
+
};
|
|
10314
|
+
if (action === "get" || action === "update")
|
|
10315
|
+
requiredId(parsed);
|
|
10316
|
+
if (action === "create") {
|
|
10317
|
+
requiredName(parsed);
|
|
10318
|
+
if (parsed.forSale === true && parsed.price === void 0) {
|
|
10319
|
+
throw invalidArgument("price is required to create an item with for_sale=true.");
|
|
10320
|
+
}
|
|
10321
|
+
}
|
|
10322
|
+
if (action === "update" && Object.keys(itemChanges(parsed)).length === 0 && parsed.imagePath === void 0) {
|
|
10323
|
+
throw invalidArgument("update needs at least one of name, description, price, for_sale, managed_pricing, or image_path.");
|
|
10324
|
+
}
|
|
10325
|
+
return parsed;
|
|
10326
|
+
}
|
|
10327
|
+
function requiredId(request) {
|
|
10328
|
+
if (request.id === void 0)
|
|
10329
|
+
throw invalidArgument(`id is required for action=${request.action}.`);
|
|
10330
|
+
return request.id;
|
|
10331
|
+
}
|
|
10332
|
+
function requiredName(request) {
|
|
10333
|
+
if (request.name === void 0)
|
|
10334
|
+
throw invalidArgument(`name is required for action=${request.action}.`);
|
|
10335
|
+
return request.name;
|
|
10336
|
+
}
|
|
10337
|
+
function itemChanges(request) {
|
|
10338
|
+
return {
|
|
10339
|
+
...request.name !== void 0 ? { name: request.name } : {},
|
|
10340
|
+
...request.description !== void 0 ? { description: request.description } : {},
|
|
10341
|
+
...request.price !== void 0 ? { price: request.price } : {},
|
|
10342
|
+
...request.forSale !== void 0 ? { forSale: request.forSale } : {},
|
|
10343
|
+
...request.managedPricing !== void 0 ? { managedPricing: request.managedPricing } : {}
|
|
10344
|
+
};
|
|
10345
|
+
}
|
|
10346
|
+
function itemLabel(kind) {
|
|
10347
|
+
return kind === "developer_product" ? "developer product" : "game pass";
|
|
10348
|
+
}
|
|
10349
|
+
function comparableName(name) {
|
|
10350
|
+
return name.trim().toLowerCase();
|
|
10351
|
+
}
|
|
10352
|
+
async function findItemNamed(client, kind, universeId, name) {
|
|
10353
|
+
const wanted = comparableName(name);
|
|
10354
|
+
let pageToken;
|
|
10355
|
+
for (let page = 0; page < MAX_NAME_SCAN_PAGES; page++) {
|
|
10356
|
+
const result = await client.listMonetizationItems(kind, universeId, pageToken);
|
|
10357
|
+
const match = result.items.find((candidate) => comparableName(candidate.name) === wanted);
|
|
10358
|
+
if (match)
|
|
10359
|
+
return match;
|
|
10360
|
+
if (!result.nextPageToken)
|
|
10361
|
+
return void 0;
|
|
10362
|
+
pageToken = result.nextPageToken;
|
|
10363
|
+
}
|
|
10364
|
+
throw new MonetizationError("duplicate_check_incomplete", `Stopped looking for an existing ${itemLabel(kind)} named "${name}" after ${MAX_NAME_SCAN_PAGES} pages, so nothing was created.`);
|
|
10365
|
+
}
|
|
10366
|
+
function requiredScopes(request) {
|
|
10367
|
+
const prefix = SCOPE_PREFIX[request.kind];
|
|
10368
|
+
return request.action === "list" || request.action === "get" ? [`${prefix}:read`] : [`${prefix}:read`, `${prefix}:write`];
|
|
10369
|
+
}
|
|
10370
|
+
function publicItem(item) {
|
|
10371
|
+
return {
|
|
10372
|
+
kind: item.kind,
|
|
10373
|
+
id: item.id,
|
|
10374
|
+
universe_id: item.universeId,
|
|
10375
|
+
name: item.name,
|
|
10376
|
+
description: item.description,
|
|
10377
|
+
for_sale: item.forSale,
|
|
10378
|
+
price: item.price,
|
|
10379
|
+
managed_pricing: item.managedPricing,
|
|
10380
|
+
icon_asset_id: item.iconAssetId,
|
|
10381
|
+
...item.createdAt !== void 0 ? { created_at: item.createdAt } : {},
|
|
10382
|
+
...item.updatedAt !== void 0 ? { updated_at: item.updatedAt } : {},
|
|
10383
|
+
...item.immutable !== void 0 ? { immutable: item.immutable } : {}
|
|
10384
|
+
};
|
|
10385
|
+
}
|
|
10386
|
+
async function resolveUniverse(request, context) {
|
|
10387
|
+
if (request.universeId !== void 0)
|
|
10388
|
+
return request.universeId;
|
|
10389
|
+
const universeId = await context.connectedUniverseId();
|
|
10390
|
+
if (!Number.isSafeInteger(universeId) || universeId <= 0) {
|
|
10391
|
+
throw new MonetizationError("unpublished_place", "The connected Studio place is not published, so it has no universe. Publish it or pass universe_id.");
|
|
10392
|
+
}
|
|
10393
|
+
return universeId;
|
|
10394
|
+
}
|
|
10395
|
+
function iconType(data) {
|
|
10396
|
+
if (data.length >= PNG_SIGNATURE2.length && data.subarray(0, PNG_SIGNATURE2.length).equals(PNG_SIGNATURE2)) {
|
|
10397
|
+
return { mimeType: "image/png", extension: "png" };
|
|
10398
|
+
}
|
|
10399
|
+
if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) {
|
|
10400
|
+
return { mimeType: "image/jpeg", extension: "jpg" };
|
|
10401
|
+
}
|
|
10402
|
+
if (data.length >= 2 && data[0] === 66 && data[1] === 77) {
|
|
10403
|
+
return { mimeType: "image/bmp", extension: "bmp" };
|
|
10404
|
+
}
|
|
10405
|
+
return void 0;
|
|
10406
|
+
}
|
|
10407
|
+
function readIcon(imagePath) {
|
|
10408
|
+
let data;
|
|
10409
|
+
try {
|
|
10410
|
+
data = readRegularFileWithinLimit(imagePath, "image_path", (byteLength) => {
|
|
10411
|
+
if (byteLength > MAX_ICON_BYTES) {
|
|
10412
|
+
throw new Error(`image_path is ${byteLength} bytes; icons are limited to ${MAX_ICON_BYTES} bytes.`);
|
|
10413
|
+
}
|
|
10414
|
+
});
|
|
10415
|
+
} catch (error) {
|
|
10416
|
+
throw invalidArgument(error instanceof Error ? error.message : String(error));
|
|
10417
|
+
}
|
|
10418
|
+
const type = iconType(data);
|
|
10419
|
+
if (!type)
|
|
10420
|
+
throw invalidArgument("image_path must be a PNG, JPEG, or BMP file.");
|
|
10421
|
+
return { data, fileName: `icon.${type.extension}`, mimeType: type.mimeType };
|
|
10422
|
+
}
|
|
10423
|
+
async function runAction(request, universeId, client, icon) {
|
|
10424
|
+
const { kind } = request;
|
|
10425
|
+
const changes = { ...itemChanges(request), ...icon ? { icon } : {} };
|
|
10426
|
+
switch (request.action) {
|
|
10427
|
+
case "list": {
|
|
10428
|
+
const page = await client.listMonetizationItems(kind, universeId, request.pageToken);
|
|
10429
|
+
return {
|
|
10430
|
+
kind,
|
|
10431
|
+
universe_id: universeId,
|
|
10432
|
+
items: page.items.map(publicItem),
|
|
10433
|
+
...page.nextPageToken ? { next_page_token: page.nextPageToken } : {}
|
|
10434
|
+
};
|
|
10435
|
+
}
|
|
10436
|
+
case "get":
|
|
10437
|
+
return { item: publicItem(await client.getMonetizationItem(kind, universeId, requiredId(request))) };
|
|
10438
|
+
case "create": {
|
|
10439
|
+
const name = requiredName(request);
|
|
10440
|
+
const existing = await findItemNamed(client, kind, universeId, name);
|
|
10441
|
+
if (existing) {
|
|
10442
|
+
throw new MonetizationError("duplicate_name", `A ${itemLabel(kind)} named "${existing.name}" already exists in universe ${universeId}. Update it or choose another name.`, { existing: publicItem(existing) });
|
|
10443
|
+
}
|
|
10444
|
+
const created = await client.createMonetizationItem(kind, universeId, {
|
|
10445
|
+
...changes,
|
|
10446
|
+
name,
|
|
10447
|
+
forSale: request.forSale ?? false
|
|
10448
|
+
});
|
|
10449
|
+
return { created: true, item: publicItem(created) };
|
|
10450
|
+
}
|
|
10451
|
+
case "update": {
|
|
10452
|
+
const id = requiredId(request);
|
|
10453
|
+
await client.updateMonetizationItem(kind, universeId, id, changes);
|
|
10454
|
+
try {
|
|
10455
|
+
return { updated: true, item: publicItem(await client.getMonetizationItem(kind, universeId, id)) };
|
|
10456
|
+
} catch (error) {
|
|
10457
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
10458
|
+
return {
|
|
10459
|
+
updated: true,
|
|
10460
|
+
item: null,
|
|
10461
|
+
warnings: [`The update succeeded, but reading the item back failed: ${reason}`]
|
|
10462
|
+
};
|
|
10463
|
+
}
|
|
10464
|
+
}
|
|
10465
|
+
}
|
|
10466
|
+
}
|
|
10467
|
+
function openCloudFailure(error, request) {
|
|
10468
|
+
const { message, status } = error;
|
|
10469
|
+
switch (status) {
|
|
10470
|
+
case 401:
|
|
10471
|
+
return { error: "unauthorized", message, hint: "Check ROBLOX_OPEN_CLOUD_API_KEY." };
|
|
10472
|
+
case 403:
|
|
10473
|
+
return {
|
|
10474
|
+
error: "forbidden",
|
|
10475
|
+
message,
|
|
10476
|
+
required_scopes: requiredScopes(request),
|
|
10477
|
+
hint: "Grant these scopes to the API key and add this experience to it."
|
|
10478
|
+
};
|
|
10479
|
+
case 404:
|
|
10480
|
+
return { error: "not_found", message, hint: "Check id, kind, and universe_id." };
|
|
10481
|
+
case 409:
|
|
10482
|
+
return { error: "conflict", message };
|
|
10483
|
+
case 429:
|
|
10484
|
+
return { error: "rate_limited", message, hint: "Wait before retrying." };
|
|
10485
|
+
default: {
|
|
10486
|
+
if (status >= 400 && status < 500) {
|
|
10487
|
+
const { field, hint } = error.details;
|
|
10488
|
+
return { error: "invalid_request", message, ...field ? { field } : {}, ...hint ? { hint } : {} };
|
|
10489
|
+
}
|
|
10490
|
+
return { error: "open_cloud_error", message, status };
|
|
10491
|
+
}
|
|
10492
|
+
}
|
|
10493
|
+
}
|
|
10494
|
+
async function runManageMonetization(request, context) {
|
|
10495
|
+
let parsed;
|
|
10496
|
+
try {
|
|
10497
|
+
parsed = parseRequest(request);
|
|
10498
|
+
if (!context.client.hasApiKey()) {
|
|
10499
|
+
const scopes = requiredScopes(parsed);
|
|
10500
|
+
throw new MonetizationError("missing_api_key", `ROBLOX_OPEN_CLOUD_API_KEY is not set. Create an Open Cloud API key with ${scopes.join(" and ")} for this experience.`, { required_scopes: scopes });
|
|
10501
|
+
}
|
|
10502
|
+
const icon = parsed.imagePath === void 0 ? void 0 : readIcon(parsed.imagePath);
|
|
10503
|
+
const universeId = await resolveUniverse(parsed, context);
|
|
10504
|
+
return await runAction(parsed, universeId, context.client, icon);
|
|
10505
|
+
} catch (error) {
|
|
10506
|
+
if (error instanceof MonetizationError) {
|
|
10507
|
+
return { error: error.code, message: error.message, ...error.details };
|
|
10508
|
+
}
|
|
10509
|
+
if (error instanceof OpenCloudRequestError && parsed)
|
|
10510
|
+
return openCloudFailure(error, parsed);
|
|
10511
|
+
throw error;
|
|
10512
|
+
}
|
|
10513
|
+
}
|
|
10514
|
+
|
|
9282
10515
|
// ../core/dist/jpeg-encoder.js
|
|
9283
10516
|
var ZIGZAG = [
|
|
9284
10517
|
0,
|
|
@@ -10232,7 +11465,7 @@ function rgbaToJpeg(rgba, width, height, quality = 80) {
|
|
|
10232
11465
|
|
|
10233
11466
|
// ../core/dist/png-encoder.js
|
|
10234
11467
|
import { deflateSync } from "zlib";
|
|
10235
|
-
var
|
|
11468
|
+
var PNG_SIGNATURE3 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
10236
11469
|
var CRC_TABLE = new Uint32Array(256);
|
|
10237
11470
|
for (let n = 0; n < 256; n++) {
|
|
10238
11471
|
let c = n;
|
|
@@ -10240,54 +11473,324 @@ for (let n = 0; n < 256; n++) {
|
|
|
10240
11473
|
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
10241
11474
|
CRC_TABLE[n] = c;
|
|
10242
11475
|
}
|
|
10243
|
-
function crc32(buf) {
|
|
10244
|
-
let crc = 4294967295;
|
|
10245
|
-
for (let i = 0; i < buf.length; i++)
|
|
10246
|
-
crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
|
|
10247
|
-
return (crc ^ 4294967295) >>> 0;
|
|
11476
|
+
function crc32(buf) {
|
|
11477
|
+
let crc = 4294967295;
|
|
11478
|
+
for (let i = 0; i < buf.length; i++)
|
|
11479
|
+
crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
|
|
11480
|
+
return (crc ^ 4294967295) >>> 0;
|
|
11481
|
+
}
|
|
11482
|
+
function writeChunk(type, data) {
|
|
11483
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
11484
|
+
const length = Buffer.alloc(4);
|
|
11485
|
+
length.writeUInt32BE(data.length);
|
|
11486
|
+
const crcInput = Buffer.concat([typeBytes, data]);
|
|
11487
|
+
const checksum = Buffer.alloc(4);
|
|
11488
|
+
checksum.writeUInt32BE(crc32(crcInput));
|
|
11489
|
+
return Buffer.concat([length, typeBytes, data, checksum]);
|
|
11490
|
+
}
|
|
11491
|
+
function rgbaToPng(rgba, width, height) {
|
|
11492
|
+
if (width <= 0 || height <= 0)
|
|
11493
|
+
throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
|
|
11494
|
+
const expected = width * height * 4;
|
|
11495
|
+
if (rgba.length < expected)
|
|
11496
|
+
throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
|
|
11497
|
+
const stride = width * 4;
|
|
11498
|
+
const filtered = Buffer.alloc(height * (1 + stride));
|
|
11499
|
+
for (let y = 0; y < height; y++) {
|
|
11500
|
+
filtered[y * (1 + stride)] = 0;
|
|
11501
|
+
rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
|
|
11502
|
+
}
|
|
11503
|
+
const ihdr = Buffer.alloc(13);
|
|
11504
|
+
ihdr.writeUInt32BE(width, 0);
|
|
11505
|
+
ihdr.writeUInt32BE(height, 4);
|
|
11506
|
+
ihdr[8] = 8;
|
|
11507
|
+
ihdr[9] = 6;
|
|
11508
|
+
ihdr[10] = 0;
|
|
11509
|
+
ihdr[11] = 0;
|
|
11510
|
+
ihdr[12] = 0;
|
|
11511
|
+
return Buffer.concat([
|
|
11512
|
+
PNG_SIGNATURE3,
|
|
11513
|
+
writeChunk("IHDR", ihdr),
|
|
11514
|
+
writeChunk("IDAT", deflateSync(filtered)),
|
|
11515
|
+
writeChunk("IEND", Buffer.alloc(0))
|
|
11516
|
+
]);
|
|
11517
|
+
}
|
|
11518
|
+
|
|
11519
|
+
// ../core/dist/host-capture.js
|
|
11520
|
+
import { spawn as spawn4 } from "child_process";
|
|
11521
|
+
import * as fs4 from "fs";
|
|
11522
|
+
import * as os4 from "os";
|
|
11523
|
+
import * as path9 from "path";
|
|
11524
|
+
|
|
11525
|
+
// ../core/dist/host-capture-macos.js
|
|
11526
|
+
import { spawn as spawn3 } from "child_process";
|
|
11527
|
+
import * as fs3 from "fs";
|
|
11528
|
+
import * as os3 from "os";
|
|
11529
|
+
import * as path8 from "path";
|
|
11530
|
+
var MAX_IMAGE_BYTES = 128 * 1024 * 1024;
|
|
11531
|
+
var MAX_OUTPUT_BYTES = 64 * 1024;
|
|
11532
|
+
var STUDIO_BUNDLE_ID = "com.Roblox.RobloxStudio";
|
|
11533
|
+
var MACOS_CAPTURE_SOURCE = String.raw`
|
|
11534
|
+
import Foundation
|
|
11535
|
+
import AppKit
|
|
11536
|
+
import CoreGraphics
|
|
11537
|
+
import ScreenCaptureKit
|
|
11538
|
+
|
|
11539
|
+
struct CaptureFailure: Error { let message: String }
|
|
11540
|
+
func fail(_ message: String) throws -> Never { throw CaptureFailure(message: message) }
|
|
11541
|
+
func emit(_ value: [String: Any]) {
|
|
11542
|
+
if let data = try? JSONSerialization.data(withJSONObject: value), let text = String(data: data, encoding: .utf8) {
|
|
11543
|
+
print(text)
|
|
11544
|
+
}
|
|
11545
|
+
}
|
|
11546
|
+
|
|
11547
|
+
// Local files can use their absolute path in the macOS window title while
|
|
11548
|
+
// Studio reports only the basename as DataModel.Name. Match that basename
|
|
11549
|
+
// after removing Studio's exact title suffix; retain ambiguity checks below.
|
|
11550
|
+
func matchesStudioTitle(_ title: String, hint: String) -> Bool {
|
|
11551
|
+
if hint.isEmpty || title == hint || title.hasPrefix(hint) { return true }
|
|
11552
|
+
let suffix = " - Roblox Studio"
|
|
11553
|
+
guard title.hasSuffix(suffix) else { return false }
|
|
11554
|
+
let place = String(title.dropLast(suffix.count))
|
|
11555
|
+
return place.hasPrefix("/") && (place as NSString).lastPathComponent == hint
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11558
|
+
@available(macOS 14.0, *)
|
|
11559
|
+
@MainActor
|
|
11560
|
+
func capture() async throws {
|
|
11561
|
+
// Never request or bypass Screen Recording permission from an MCP call.
|
|
11562
|
+
guard CGPreflightScreenCaptureAccess() else {
|
|
11563
|
+
try fail("macOS Screen Recording permission is not granted to the MCP host. Enable it in System Settings > Privacy & Security > Screen Recording, then restart the MCP host.")
|
|
11564
|
+
}
|
|
11565
|
+
// A command-line helper must establish its WindowServer connection before
|
|
11566
|
+
// ScreenCaptureKit uses Core Graphics. Do not activate or create any UI.
|
|
11567
|
+
NSApplication.shared.setActivationPolicy(.prohibited)
|
|
11568
|
+
let env = ProcessInfo.processInfo.environment
|
|
11569
|
+
let hint = env["MCP_CAPTURE_TITLE_HINT"] ?? ""
|
|
11570
|
+
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
11571
|
+
let candidates = content.windows.filter { window in
|
|
11572
|
+
window.owningApplication?.bundleIdentifier == "com.Roblox.RobloxStudio" &&
|
|
11573
|
+
window.windowLayer == 0 && window.isOnScreen &&
|
|
11574
|
+
window.frame.width > 0 && window.frame.height > 0 &&
|
|
11575
|
+
matchesStudioTitle(window.title ?? "", hint: hint)
|
|
11576
|
+
}
|
|
11577
|
+
guard candidates.count == 1, let window = candidates.first, let app = window.owningApplication else {
|
|
11578
|
+
try fail(candidates.isEmpty ? "no visible Roblox Studio window matches the requested place title" : "multiple Roblox Studio windows match the requested place title; the capture is ambiguous")
|
|
11579
|
+
}
|
|
11580
|
+
if let expected = env["MCP_CAPTURE_EXPECTED_IDENTITY"], !expected.isEmpty {
|
|
11581
|
+
guard let data = expected.data(using: .utf8),
|
|
11582
|
+
let identity = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
11583
|
+
(identity["windowId"] as? NSNumber)?.uint32Value == window.windowID,
|
|
11584
|
+
(identity["processId"] as? NSNumber)?.int32Value == app.processID,
|
|
11585
|
+
identity["bundleIdentifier"] as? String == app.bundleIdentifier else {
|
|
11586
|
+
try fail("the selected Roblox Studio window identity changed between captures")
|
|
11587
|
+
}
|
|
11588
|
+
}
|
|
11589
|
+
let filter = SCContentFilter(desktopIndependentWindow: window)
|
|
11590
|
+
let scale = CGFloat(filter.pointPixelScale)
|
|
11591
|
+
let pixelWidth = filter.contentRect.width * scale
|
|
11592
|
+
let pixelHeight = filter.contentRect.height * scale
|
|
11593
|
+
guard pixelWidth.isFinite, pixelHeight.isFinite, pixelWidth > 0, pixelHeight > 0,
|
|
11594
|
+
pixelWidth <= 16384, pixelHeight <= 16384 else { try fail("invalid Studio window capture dimensions") }
|
|
11595
|
+
let width = Int(pixelWidth.rounded(.up))
|
|
11596
|
+
let height = Int(pixelHeight.rounded(.up))
|
|
11597
|
+
guard width * height * 4 <= 134217728 else { try fail("Studio window capture exceeds the image byte limit") }
|
|
11598
|
+
let config = SCStreamConfiguration()
|
|
11599
|
+
config.width = width
|
|
11600
|
+
config.height = height
|
|
11601
|
+
config.showsCursor = false
|
|
11602
|
+
config.ignoreShadowsSingleWindow = true
|
|
11603
|
+
config.ignoreGlobalClipSingleWindow = true
|
|
11604
|
+
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
|
|
11605
|
+
guard image.width == width, image.height == height else { try fail("Studio window capture returned unexpected dimensions") }
|
|
11606
|
+
var bytes = [UInt8](repeating: 0, count: width * height * 4)
|
|
11607
|
+
try bytes.withUnsafeMutableBytes { raw in
|
|
11608
|
+
guard let context = CGContext(data: raw.baseAddress, width: width, height: height,
|
|
11609
|
+
bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
11610
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else {
|
|
11611
|
+
try fail("could not allocate the RGBA capture context")
|
|
11612
|
+
}
|
|
11613
|
+
// Quartz draws the image upright into row-major top-to-bottom bitmap storage.
|
|
11614
|
+
context.setBlendMode(.copy)
|
|
11615
|
+
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
11616
|
+
}
|
|
11617
|
+
guard let output = env["MCP_CAPTURE_OUT"] else { try fail("missing capture output path") }
|
|
11618
|
+
try Data(bytes).write(to: URL(fileURLWithPath: output), options: .atomic)
|
|
11619
|
+
emit(["ok": true, "width": width, "height": height, "title": String((window.title ?? "").prefix(2048)),
|
|
11620
|
+
"identity": ["windowId": window.windowID, "processId": app.processID, "bundleIdentifier": app.bundleIdentifier]])
|
|
11621
|
+
}
|
|
11622
|
+
|
|
11623
|
+
@main struct Main {
|
|
11624
|
+
@MainActor
|
|
11625
|
+
static func main() async {
|
|
11626
|
+
do {
|
|
11627
|
+
if #available(macOS 14.0, *) { try await capture() }
|
|
11628
|
+
else { try fail("macOS host window capture requires macOS 14 or newer") }
|
|
11629
|
+
} catch {
|
|
11630
|
+
emit(["ok": false, "error": String(((error as? CaptureFailure)?.message ?? error.localizedDescription).prefix(2048))])
|
|
11631
|
+
}
|
|
11632
|
+
}
|
|
11633
|
+
}
|
|
11634
|
+
`;
|
|
11635
|
+
function runBoundedCaptureProcess(executable, args, timeoutMs, env = process.env) {
|
|
11636
|
+
return new Promise((resolve6, reject) => {
|
|
11637
|
+
const processGroup = process.platform !== "win32";
|
|
11638
|
+
const child = spawn3(executable, args, { env, stdio: ["ignore", "pipe", "pipe"], detached: processGroup });
|
|
11639
|
+
let stdout = "";
|
|
11640
|
+
let stderr = "";
|
|
11641
|
+
let outputBytes = 0;
|
|
11642
|
+
let failure;
|
|
11643
|
+
const stop = (message) => {
|
|
11644
|
+
failure ??= new Error(message);
|
|
11645
|
+
try {
|
|
11646
|
+
if (processGroup && child.pid)
|
|
11647
|
+
process.kill(-child.pid, "SIGKILL");
|
|
11648
|
+
else
|
|
11649
|
+
child.kill("SIGKILL");
|
|
11650
|
+
} catch {
|
|
11651
|
+
child.kill("SIGKILL");
|
|
11652
|
+
}
|
|
11653
|
+
};
|
|
11654
|
+
const timer = setTimeout(() => stop(`macOS host capture helper timed out after ${timeoutMs}ms`), timeoutMs);
|
|
11655
|
+
child.stdout.setEncoding("utf8");
|
|
11656
|
+
child.stderr.setEncoding("utf8");
|
|
11657
|
+
const collect = (chunk, isError) => {
|
|
11658
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
11659
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
11660
|
+
stop("macOS host capture helper exceeded its output byte limit");
|
|
11661
|
+
return;
|
|
11662
|
+
}
|
|
11663
|
+
if (isError)
|
|
11664
|
+
stderr += chunk;
|
|
11665
|
+
else
|
|
11666
|
+
stdout += chunk;
|
|
11667
|
+
};
|
|
11668
|
+
child.stdout.on("data", (chunk) => collect(chunk, false));
|
|
11669
|
+
child.stderr.on("data", (chunk) => collect(chunk, true));
|
|
11670
|
+
child.on("error", (error) => {
|
|
11671
|
+
failure ??= error;
|
|
11672
|
+
});
|
|
11673
|
+
child.on("close", (code) => {
|
|
11674
|
+
clearTimeout(timer);
|
|
11675
|
+
if (failure)
|
|
11676
|
+
reject(failure);
|
|
11677
|
+
else if (code !== 0)
|
|
11678
|
+
reject(new Error(`macOS host capture helper exited with code ${code}: ${stderr.trim().slice(0, 600)}`));
|
|
11679
|
+
else
|
|
11680
|
+
resolve6(stdout);
|
|
11681
|
+
});
|
|
11682
|
+
});
|
|
11683
|
+
}
|
|
11684
|
+
var MISSING_SWIFT_TOOLCHAIN = "macOS host capture requires Xcode or the Xcode Command Line Tools (xcrun swiftc). Install them with `xcode-select --install`, then retry the capture.";
|
|
11685
|
+
async function assertSwiftToolchain(env = process.env, selectedDeveloperDir = () => runBoundedCaptureProcess("/usr/bin/xcode-select", ["-p"], 5e3, env), exists = fs3.existsSync) {
|
|
11686
|
+
let developerDir = env.DEVELOPER_DIR?.trim();
|
|
11687
|
+
if (!developerDir) {
|
|
11688
|
+
try {
|
|
11689
|
+
developerDir = (await selectedDeveloperDir()).trim();
|
|
11690
|
+
} catch {
|
|
11691
|
+
throw new Error(MISSING_SWIFT_TOOLCHAIN);
|
|
11692
|
+
}
|
|
11693
|
+
}
|
|
11694
|
+
const compilers = [
|
|
11695
|
+
path8.join(developerDir, "usr", "bin", "swiftc"),
|
|
11696
|
+
path8.join(developerDir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "bin", "swiftc")
|
|
11697
|
+
];
|
|
11698
|
+
if (!path8.isAbsolute(developerDir) || !compilers.some((compiler) => exists(compiler))) {
|
|
11699
|
+
throw new Error(MISSING_SWIFT_TOOLCHAIN);
|
|
11700
|
+
}
|
|
11701
|
+
}
|
|
11702
|
+
var compiledHelper;
|
|
11703
|
+
async function getHelper() {
|
|
11704
|
+
if (!compiledHelper) {
|
|
11705
|
+
compiledHelper = (async () => {
|
|
11706
|
+
await assertSwiftToolchain();
|
|
11707
|
+
const directory = fs3.mkdtempSync(path8.join(os3.tmpdir(), "robloxstudio-mcp-macos-helper-"));
|
|
11708
|
+
const cleanup = () => fs3.rmSync(directory, { recursive: true, force: true });
|
|
11709
|
+
try {
|
|
11710
|
+
const source = path8.join(directory, "capture.swift");
|
|
11711
|
+
const executable = path8.join(directory, "capture");
|
|
11712
|
+
fs3.writeFileSync(source, MACOS_CAPTURE_SOURCE, { mode: 384 });
|
|
11713
|
+
await runBoundedCaptureProcess("/usr/bin/xcrun", [
|
|
11714
|
+
"swiftc",
|
|
11715
|
+
"-parse-as-library",
|
|
11716
|
+
source,
|
|
11717
|
+
"-o",
|
|
11718
|
+
executable,
|
|
11719
|
+
"-module-cache-path",
|
|
11720
|
+
path8.join(directory, "modules")
|
|
11721
|
+
], 6e4);
|
|
11722
|
+
process.once("exit", cleanup);
|
|
11723
|
+
return executable;
|
|
11724
|
+
} catch (error) {
|
|
11725
|
+
cleanup();
|
|
11726
|
+
throw error;
|
|
11727
|
+
}
|
|
11728
|
+
})();
|
|
11729
|
+
compiledHelper.catch(() => {
|
|
11730
|
+
compiledHelper = void 0;
|
|
11731
|
+
});
|
|
11732
|
+
}
|
|
11733
|
+
return compiledHelper;
|
|
11734
|
+
}
|
|
11735
|
+
async function prepareMacHostCapture() {
|
|
11736
|
+
await getHelper();
|
|
10248
11737
|
}
|
|
10249
|
-
function
|
|
10250
|
-
|
|
10251
|
-
|
|
10252
|
-
|
|
10253
|
-
|
|
10254
|
-
const checksum = Buffer.alloc(4);
|
|
10255
|
-
checksum.writeUInt32BE(crc32(crcInput));
|
|
10256
|
-
return Buffer.concat([length, typeBytes, data, checksum]);
|
|
11738
|
+
function validIdentity(value) {
|
|
11739
|
+
if (!value || typeof value !== "object")
|
|
11740
|
+
return false;
|
|
11741
|
+
const id = value;
|
|
11742
|
+
return Number.isSafeInteger(id.windowId) && id.windowId > 0 && id.windowId <= 4294967295 && Number.isSafeInteger(id.processId) && id.processId > 0 && id.processId <= 2147483647 && id.bundleIdentifier === STUDIO_BUNDLE_ID;
|
|
10257
11743
|
}
|
|
10258
|
-
function
|
|
10259
|
-
if (
|
|
10260
|
-
throw new Error(
|
|
10261
|
-
const
|
|
10262
|
-
if (
|
|
10263
|
-
throw new Error(
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
11744
|
+
function parseMacCaptureReport(stdout, expectedIdentity) {
|
|
11745
|
+
if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES)
|
|
11746
|
+
throw new Error("macOS capture report exceeds its byte limit");
|
|
11747
|
+
const value = JSON.parse(stdout.trim());
|
|
11748
|
+
if (!value || typeof value !== "object")
|
|
11749
|
+
throw new Error("invalid macOS capture report");
|
|
11750
|
+
if (value.ok === false && typeof value.error === "string")
|
|
11751
|
+
return { ok: false, error: value.error.slice(0, 2048) };
|
|
11752
|
+
const { width, height, title, identity } = value;
|
|
11753
|
+
if (value.ok !== true || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > 16384 || height > 16384 || width * height * 4 > MAX_IMAGE_BYTES || typeof title !== "string" || title.length > 2048 || !validIdentity(identity)) {
|
|
11754
|
+
throw new Error("invalid macOS capture dimensions or window identity");
|
|
11755
|
+
}
|
|
11756
|
+
if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
|
|
11757
|
+
throw new Error("the selected Roblox Studio window identity changed between captures");
|
|
11758
|
+
}
|
|
11759
|
+
return { ok: true, width, height, title, identity };
|
|
11760
|
+
}
|
|
11761
|
+
async function runMacCapture(output, titleHint, expectedIdentity) {
|
|
11762
|
+
const executable = await getHelper();
|
|
11763
|
+
return runBoundedCaptureProcess(executable, [], 2e4, {
|
|
11764
|
+
...process.env,
|
|
11765
|
+
MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
|
|
11766
|
+
MCP_CAPTURE_OUT: output,
|
|
11767
|
+
MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
|
|
11768
|
+
});
|
|
11769
|
+
}
|
|
11770
|
+
async function captureMacStudioWindow(titleHint, expectedIdentity, runCapture = runMacCapture) {
|
|
11771
|
+
const directory = fs3.mkdtempSync(path8.join(os3.tmpdir(), "robloxstudio-mcp-macos-capture-"));
|
|
11772
|
+
try {
|
|
11773
|
+
const output = path8.join(directory, "capture.rgba");
|
|
11774
|
+
const stdout = await runCapture(output, titleHint, expectedIdentity);
|
|
11775
|
+
const report = parseMacCaptureReport(stdout, expectedIdentity);
|
|
11776
|
+
if (!report.ok)
|
|
11777
|
+
return report;
|
|
11778
|
+
const expectedBytes = report.width * report.height * 4;
|
|
11779
|
+
const stat2 = fs3.lstatSync(output);
|
|
11780
|
+
if (!stat2.isFile() || stat2.size !== expectedBytes)
|
|
11781
|
+
throw new Error("macOS capture returned an invalid RGBA byte count");
|
|
11782
|
+
const rgba = fs3.readFileSync(output);
|
|
11783
|
+
if (rgba.length !== expectedBytes)
|
|
11784
|
+
throw new Error("macOS capture RGBA byte count changed during read");
|
|
11785
|
+
return { ok: true, capture: { width: report.width, height: report.height, title: report.title, identity: report.identity, rgba } };
|
|
11786
|
+
} catch (error) {
|
|
11787
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
11788
|
+
} finally {
|
|
11789
|
+
fs3.rmSync(directory, { recursive: true, force: true });
|
|
10269
11790
|
}
|
|
10270
|
-
const ihdr = Buffer.alloc(13);
|
|
10271
|
-
ihdr.writeUInt32BE(width, 0);
|
|
10272
|
-
ihdr.writeUInt32BE(height, 4);
|
|
10273
|
-
ihdr[8] = 8;
|
|
10274
|
-
ihdr[9] = 6;
|
|
10275
|
-
ihdr[10] = 0;
|
|
10276
|
-
ihdr[11] = 0;
|
|
10277
|
-
ihdr[12] = 0;
|
|
10278
|
-
return Buffer.concat([
|
|
10279
|
-
PNG_SIGNATURE2,
|
|
10280
|
-
writeChunk("IHDR", ihdr),
|
|
10281
|
-
writeChunk("IDAT", deflateSync(filtered)),
|
|
10282
|
-
writeChunk("IEND", Buffer.alloc(0))
|
|
10283
|
-
]);
|
|
10284
11791
|
}
|
|
10285
11792
|
|
|
10286
11793
|
// ../core/dist/host-capture.js
|
|
10287
|
-
import { spawn as spawn2 } from "child_process";
|
|
10288
|
-
import * as fs3 from "fs";
|
|
10289
|
-
import * as os3 from "os";
|
|
10290
|
-
import * as path5 from "path";
|
|
10291
11794
|
var HOST_CAPTURE_TIMEOUT_MS = 2e4;
|
|
10292
11795
|
var MARKER_MIN_RB = 200;
|
|
10293
11796
|
var MARKER_MAX_G = 60;
|
|
@@ -10297,10 +11800,14 @@ function isHostCaptureDisabled(env = process.env) {
|
|
|
10297
11800
|
return value === "0" || value === "false" || value === "off";
|
|
10298
11801
|
}
|
|
10299
11802
|
function isHostCaptureSupported(platform = process.platform) {
|
|
10300
|
-
return platform === "win32";
|
|
11803
|
+
return platform === "win32" || platform === "darwin";
|
|
10301
11804
|
}
|
|
10302
11805
|
function hostCaptureUnsupportedReason(platform = process.platform) {
|
|
10303
|
-
return `host window capture is only implemented on Windows (this is ${platform})`;
|
|
11806
|
+
return `host window capture is only implemented on Windows and macOS (this is ${platform})`;
|
|
11807
|
+
}
|
|
11808
|
+
async function prepareHostWindowCapture() {
|
|
11809
|
+
if (process.platform === "darwin" && !isHostCaptureDisabled())
|
|
11810
|
+
await prepareMacHostCapture();
|
|
10304
11811
|
}
|
|
10305
11812
|
function isUniformFrame(rgba, width, height) {
|
|
10306
11813
|
const pixels = width * height;
|
|
@@ -10450,17 +11957,40 @@ public static class McpStudioCapture {
|
|
|
10450
11957
|
}
|
|
10451
11958
|
"@
|
|
10452
11959
|
function Emit($obj) { Write-Output ($obj | ConvertTo-Json -Compress) }
|
|
11960
|
+
# Local files show their absolute path in the window title while Studio reports
|
|
11961
|
+
# only the basename as DataModel.Name. Match that basename after removing
|
|
11962
|
+
# Studio's exact title suffix; the ambiguity checks below still apply.
|
|
11963
|
+
function Test-StudioTitle([string]$title, [string]$hint) {
|
|
11964
|
+
if (-not $hint -or $title.StartsWith($hint, [StringComparison]::Ordinal)) { return $true }
|
|
11965
|
+
$suffix = ' - Roblox Studio'
|
|
11966
|
+
if (-not $title.EndsWith($suffix, [StringComparison]::Ordinal)) { return $false }
|
|
11967
|
+
$place = $title.Substring(0, $title.Length - $suffix.Length)
|
|
11968
|
+
if ($place -cnotmatch '^(?:[A-Za-z]:[\\/]|\\\\|/)') { return $false }
|
|
11969
|
+
return $place.Substring($place.LastIndexOfAny([char[]]@([char]'\', [char]'/')) + 1) -ceq $hint
|
|
11970
|
+
}
|
|
10453
11971
|
$hint = $env:MCP_CAPTURE_TITLE_HINT
|
|
10454
11972
|
$outFile = $env:MCP_CAPTURE_OUT
|
|
10455
11973
|
$candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
|
|
10456
11974
|
if ($candidates.Count -eq 0) { Emit @{ ok = $false; error = 'no visible Roblox Studio window was found' }; exit 0 }
|
|
10457
|
-
$
|
|
10458
|
-
|
|
10459
|
-
if ($
|
|
10460
|
-
|
|
10461
|
-
|
|
10462
|
-
|
|
11975
|
+
$matching = @($candidates | Where-Object { Test-StudioTitle $_.Title $hint })
|
|
11976
|
+
$expectedJson = $env:MCP_CAPTURE_EXPECTED_IDENTITY
|
|
11977
|
+
if ($expectedJson) {
|
|
11978
|
+
try { $expected = $expectedJson | ConvertFrom-Json -ErrorAction Stop }
|
|
11979
|
+
catch { Emit @{ ok = $false; error = 'invalid expected Studio window identity' }; exit 0 }
|
|
11980
|
+
$matching = @($matching | Where-Object {
|
|
11981
|
+
$_.Handle.ToInt64() -eq $expected.windowId -and $_.Pid -eq $expected.processId -and
|
|
11982
|
+
$expected.bundleIdentifier -ceq 'RobloxStudioBeta'
|
|
11983
|
+
})
|
|
11984
|
+
if ($matching.Count -ne 1) {
|
|
11985
|
+
Emit @{ ok = $false; error = 'the selected Roblox Studio window identity changed between captures' }; exit 0
|
|
11986
|
+
}
|
|
11987
|
+
} elseif ($matching.Count -ne 1) {
|
|
11988
|
+
$reason = if ($matching.Count -eq 0) { 'no visible Roblox Studio window matches the requested place title' }
|
|
11989
|
+
else { 'multiple Roblox Studio windows match the requested place title; the capture is ambiguous' }
|
|
11990
|
+
Emit @{ ok = $false; error = $reason }; exit 0
|
|
10463
11991
|
}
|
|
11992
|
+
$pick = $matching[0]
|
|
11993
|
+
$identity = @{ windowId = $pick.Handle.ToInt64(); processId = [long]$pick.Pid; bundleIdentifier = 'RobloxStudioBeta' }
|
|
10464
11994
|
if ([McpStudioCapture]::IsIconic($pick.Handle)) {
|
|
10465
11995
|
Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
|
|
10466
11996
|
}
|
|
@@ -10482,83 +12012,110 @@ $bytes = New-Object byte[] ($data.Stride * $h)
|
|
|
10482
12012
|
$stride = $data.Stride
|
|
10483
12013
|
$bmp.UnlockBits($data); $bmp.Dispose()
|
|
10484
12014
|
[System.IO.File]::WriteAllBytes($outFile, $bytes)
|
|
10485
|
-
Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title }
|
|
12015
|
+
Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title; identity = $identity }
|
|
10486
12016
|
`;
|
|
12017
|
+
function validWindowsIdentity(value) {
|
|
12018
|
+
return value !== null && typeof value === "object" && "windowId" in value && typeof value.windowId === "number" && Number.isSafeInteger(value.windowId) && value.windowId > 0 && "processId" in value && typeof value.processId === "number" && Number.isSafeInteger(value.processId) && value.processId > 0 && value.processId <= 4294967295 && "bundleIdentifier" in value && value.bundleIdentifier === "RobloxStudioBeta";
|
|
12019
|
+
}
|
|
12020
|
+
function parseWindowsCaptureReport(value, expectedIdentity) {
|
|
12021
|
+
if (value === null || typeof value !== "object" || !("ok" in value))
|
|
12022
|
+
throw new Error("invalid Windows capture report");
|
|
12023
|
+
if (value.ok === false && "error" in value && typeof value.error === "string")
|
|
12024
|
+
return { ok: false, error: value.error };
|
|
12025
|
+
if (!("identity" in value) || !validWindowsIdentity(value.identity))
|
|
12026
|
+
throw new Error("invalid Windows capture window identity");
|
|
12027
|
+
const identity = value.identity;
|
|
12028
|
+
if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
|
|
12029
|
+
throw new Error("the selected Roblox Studio window identity changed between captures");
|
|
12030
|
+
}
|
|
12031
|
+
if (value.ok !== true || !("width" in value) || typeof value.width !== "number" || !Number.isSafeInteger(value.width) || value.width <= 0 || !("height" in value) || typeof value.height !== "number" || !Number.isSafeInteger(value.height) || value.height <= 0 || !("stride" in value) || typeof value.stride !== "number" || !Number.isSafeInteger(value.stride) || value.stride < value.width * 4 || value.width > 16384 || value.height > 16384 || value.stride * value.height > 128 * 1024 * 1024 || !("title" in value) || typeof value.title !== "string") {
|
|
12032
|
+
throw new Error("invalid Windows capture dimensions or title");
|
|
12033
|
+
}
|
|
12034
|
+
return { ok: true, width: value.width, height: value.height, stride: value.stride, title: value.title, identity };
|
|
12035
|
+
}
|
|
10487
12036
|
function powershellPath() {
|
|
10488
12037
|
const systemRoot = process.env.SystemRoot ?? process.env.windir;
|
|
10489
12038
|
if (systemRoot) {
|
|
10490
|
-
const candidate =
|
|
10491
|
-
if (
|
|
12039
|
+
const candidate = path9.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
12040
|
+
if (fs4.existsSync(candidate))
|
|
10492
12041
|
return candidate;
|
|
10493
12042
|
}
|
|
10494
12043
|
return "powershell.exe";
|
|
10495
12044
|
}
|
|
10496
|
-
function runWindowsCapture(titleHint, outFile) {
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
child.
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
|
|
12045
|
+
function runWindowsCapture(titleHint, outFile, expectedIdentity) {
|
|
12046
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
12047
|
+
const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
|
|
12048
|
+
const child = spawn4(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
|
|
12049
|
+
env: {
|
|
12050
|
+
...process.env,
|
|
12051
|
+
MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
|
|
12052
|
+
MCP_CAPTURE_OUT: outFile,
|
|
12053
|
+
MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
|
|
12054
|
+
},
|
|
12055
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
12056
|
+
windowsHide: true
|
|
12057
|
+
});
|
|
12058
|
+
let stdout = "";
|
|
12059
|
+
let stderr = "";
|
|
12060
|
+
let settled = false;
|
|
12061
|
+
const timer = setTimeout(() => {
|
|
12062
|
+
if (settled)
|
|
12063
|
+
return;
|
|
12064
|
+
settled = true;
|
|
12065
|
+
child.kill();
|
|
12066
|
+
reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
|
|
12067
|
+
}, HOST_CAPTURE_TIMEOUT_MS);
|
|
12068
|
+
child.stdout.setEncoding("utf8");
|
|
12069
|
+
child.stderr.setEncoding("utf8");
|
|
12070
|
+
child.stdout.on("data", (chunk) => {
|
|
12071
|
+
stdout += chunk;
|
|
12072
|
+
});
|
|
12073
|
+
child.stderr.on("data", (chunk) => {
|
|
12074
|
+
stderr += chunk;
|
|
12075
|
+
});
|
|
12076
|
+
child.on("error", (error) => {
|
|
12077
|
+
if (settled)
|
|
12078
|
+
return;
|
|
12079
|
+
settled = true;
|
|
12080
|
+
clearTimeout(timer);
|
|
12081
|
+
reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
|
|
12082
|
+
});
|
|
12083
|
+
child.on("close", (code) => {
|
|
12084
|
+
if (settled)
|
|
12085
|
+
return;
|
|
12086
|
+
settled = true;
|
|
12087
|
+
clearTimeout(timer);
|
|
12088
|
+
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
12089
|
+
const last = lines[lines.length - 1];
|
|
12090
|
+
if (last && last.startsWith("{")) {
|
|
12091
|
+
try {
|
|
12092
|
+
resolve6(JSON.parse(last));
|
|
10531
12093
|
return;
|
|
10532
|
-
|
|
10533
|
-
clearTimeout(timer);
|
|
10534
|
-
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
10535
|
-
const last = lines[lines.length - 1];
|
|
10536
|
-
if (last && last.startsWith("{")) {
|
|
10537
|
-
try {
|
|
10538
|
-
resolve5(JSON.parse(last));
|
|
10539
|
-
return;
|
|
10540
|
-
} catch {
|
|
10541
|
-
}
|
|
12094
|
+
} catch {
|
|
10542
12095
|
}
|
|
10543
|
-
|
|
10544
|
-
|
|
10545
|
-
});
|
|
12096
|
+
}
|
|
12097
|
+
const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
|
|
12098
|
+
reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
|
|
10546
12099
|
});
|
|
12100
|
+
return promise;
|
|
10547
12101
|
}
|
|
10548
|
-
async function captureStudioWindow(titleHint) {
|
|
12102
|
+
async function captureStudioWindow(titleHint, expectedIdentity) {
|
|
10549
12103
|
if (isHostCaptureDisabled()) {
|
|
10550
12104
|
return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
|
|
10551
12105
|
}
|
|
10552
12106
|
if (!isHostCaptureSupported()) {
|
|
10553
12107
|
return { ok: false, error: hostCaptureUnsupportedReason() };
|
|
10554
12108
|
}
|
|
10555
|
-
|
|
12109
|
+
if (process.platform === "darwin")
|
|
12110
|
+
return captureMacStudioWindow(titleHint, expectedIdentity);
|
|
12111
|
+
const outFile = path9.join(os4.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
|
|
10556
12112
|
try {
|
|
10557
|
-
const report = await runWindowsCapture(titleHint, outFile);
|
|
10558
|
-
if (!report.ok
|
|
10559
|
-
return
|
|
10560
|
-
|
|
10561
|
-
|
|
12113
|
+
const report = parseWindowsCaptureReport(await runWindowsCapture(titleHint, outFile, expectedIdentity), expectedIdentity);
|
|
12114
|
+
if (!report.ok)
|
|
12115
|
+
return report;
|
|
12116
|
+
const raw = fs4.readFileSync(outFile);
|
|
12117
|
+
if (raw.length !== report.stride * report.height)
|
|
12118
|
+
throw new Error("Windows capture returned an invalid BGRA byte count");
|
|
10562
12119
|
const { width, height, stride } = report;
|
|
10563
12120
|
const rgba = Buffer.alloc(width * height * 4);
|
|
10564
12121
|
for (let y = 0; y < height; y++) {
|
|
@@ -10573,17 +12130,20 @@ async function captureStudioWindow(titleHint) {
|
|
|
10573
12130
|
rgba[d + 3] = 255;
|
|
10574
12131
|
}
|
|
10575
12132
|
}
|
|
10576
|
-
return { ok: true, capture: { width, height, rgba, title: report.title
|
|
12133
|
+
return { ok: true, capture: { width, height, rgba, title: report.title, identity: report.identity } };
|
|
10577
12134
|
} catch (error) {
|
|
10578
12135
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
10579
12136
|
} finally {
|
|
10580
|
-
|
|
12137
|
+
fs4.rmSync(outFile, { force: true });
|
|
10581
12138
|
}
|
|
10582
12139
|
}
|
|
10583
12140
|
|
|
10584
12141
|
// ../core/dist/tools/index.js
|
|
10585
|
-
import * as
|
|
10586
|
-
import * as
|
|
12142
|
+
import * as fs5 from "fs";
|
|
12143
|
+
import * as path10 from "path";
|
|
12144
|
+
function sameHostWindowIdentity(actual, expected) {
|
|
12145
|
+
return actual?.windowId === expected.windowId && actual.processId === expected.processId && actual.bundleIdentifier === expected.bundleIdentifier;
|
|
12146
|
+
}
|
|
10587
12147
|
var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
|
|
10588
12148
|
var MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
10589
12149
|
var MAX_MATRIX_IMAGE_BYTES = 8e6;
|
|
@@ -10669,7 +12229,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
|
|
|
10669
12229
|
};
|
|
10670
12230
|
}
|
|
10671
12231
|
function sleep(ms) {
|
|
10672
|
-
return new Promise((
|
|
12232
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
10673
12233
|
}
|
|
10674
12234
|
function errorMessage(error) {
|
|
10675
12235
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -10763,9 +12323,9 @@ function compactSoundReference(sound) {
|
|
|
10763
12323
|
name: stringField(sound, "name"),
|
|
10764
12324
|
className: stringField(sound, "className")
|
|
10765
12325
|
};
|
|
10766
|
-
const
|
|
10767
|
-
if (
|
|
10768
|
-
compact.path =
|
|
12326
|
+
const path11 = stringField(sound, "path");
|
|
12327
|
+
if (path11)
|
|
12328
|
+
compact.path = path11;
|
|
10769
12329
|
const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
|
|
10770
12330
|
if (assetId !== void 0)
|
|
10771
12331
|
compact.assetId = assetId;
|
|
@@ -10828,8 +12388,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
|
|
|
10828
12388
|
if (typeof sourcePath !== "string" || sourcePath === "") {
|
|
10829
12389
|
throw new Error("baseline_path must be a non-empty string when provided");
|
|
10830
12390
|
}
|
|
10831
|
-
const resolved =
|
|
10832
|
-
const parsed = JSON.parse(
|
|
12391
|
+
const resolved = path10.resolve(sourcePath);
|
|
12392
|
+
const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
|
|
10833
12393
|
const record = asRecord(parsed);
|
|
10834
12394
|
if (!record)
|
|
10835
12395
|
throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
|
|
@@ -11331,6 +12891,7 @@ var RobloxStudioTools = class {
|
|
|
11331
12891
|
cookieClient;
|
|
11332
12892
|
instanceManager;
|
|
11333
12893
|
managedConnectionAssociations = Promise.resolve();
|
|
12894
|
+
unsubscribePeerRegistered;
|
|
11334
12895
|
hostWindowCapture = captureStudioWindow;
|
|
11335
12896
|
hostViewportRects = /* @__PURE__ */ new Map();
|
|
11336
12897
|
viewportCaptureQueues = /* @__PURE__ */ new Map();
|
|
@@ -11340,7 +12901,7 @@ var RobloxStudioTools = class {
|
|
|
11340
12901
|
this.openCloudClient = new OpenCloudClient();
|
|
11341
12902
|
this.cookieClient = new RobloxCookieClient();
|
|
11342
12903
|
this.instanceManager = new StudioInstanceManager();
|
|
11343
|
-
this.bridge.onPeerRegistered((peer) => {
|
|
12904
|
+
this.unsubscribePeerRegistered = this.bridge.onPeerRegistered((peer) => {
|
|
11344
12905
|
const instanceManager = this.instanceManager;
|
|
11345
12906
|
const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
|
|
11346
12907
|
this.managedConnectionAssociations = association.catch((error) => {
|
|
@@ -11351,6 +12912,10 @@ var RobloxStudioTools = class {
|
|
|
11351
12912
|
getStudioLifecycleCapabilities() {
|
|
11352
12913
|
return this.instanceManager.getLifecycleCapabilities();
|
|
11353
12914
|
}
|
|
12915
|
+
async dispose() {
|
|
12916
|
+
this.unsubscribePeerRegistered();
|
|
12917
|
+
await this.managedConnectionAssociations;
|
|
12918
|
+
}
|
|
11354
12919
|
_textResult(body) {
|
|
11355
12920
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
11356
12921
|
}
|
|
@@ -11474,8 +13039,8 @@ var RobloxStudioTools = class {
|
|
|
11474
13039
|
}
|
|
11475
13040
|
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
|
|
11476
13041
|
}
|
|
11477
|
-
//
|
|
11478
|
-
//
|
|
13042
|
+
// Honor an explicitly selected client for viewport/input operations; otherwise
|
|
13043
|
+
// prefer the first client in the scope, then retain the default Peer's Instance.
|
|
11479
13044
|
_resolveRuntime(instance_id) {
|
|
11480
13045
|
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
11481
13046
|
if (!resolved.ok)
|
|
@@ -11487,7 +13052,9 @@ var RobloxStudioTools = class {
|
|
|
11487
13052
|
data: this._routingErrorData()
|
|
11488
13053
|
});
|
|
11489
13054
|
}
|
|
11490
|
-
const
|
|
13055
|
+
const clients = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role));
|
|
13056
|
+
const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
|
|
13057
|
+
const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
|
|
11491
13058
|
return {
|
|
11492
13059
|
instanceId: client?.instanceId ?? resolved.targetInstanceId,
|
|
11493
13060
|
clientRole: client?.role
|
|
@@ -11773,8 +13340,8 @@ var RobloxStudioTools = class {
|
|
|
11773
13340
|
timedOut: true
|
|
11774
13341
|
};
|
|
11775
13342
|
}
|
|
11776
|
-
async getFileTree(
|
|
11777
|
-
const response = await this._callSingle("/api/file-tree", { path:
|
|
13343
|
+
async getFileTree(path11 = "", instance_id) {
|
|
13344
|
+
const response = await this._callSingle("/api/file-tree", { path: path11 }, void 0, instance_id);
|
|
11778
13345
|
return {
|
|
11779
13346
|
content: [
|
|
11780
13347
|
{
|
|
@@ -11806,6 +13373,28 @@ var RobloxStudioTools = class {
|
|
|
11806
13373
|
]
|
|
11807
13374
|
};
|
|
11808
13375
|
}
|
|
13376
|
+
async manageMonetization(request, instance_id) {
|
|
13377
|
+
return this._textResult(await runManageMonetization(request, {
|
|
13378
|
+
client: this.openCloudClient,
|
|
13379
|
+
connectedUniverseId: () => this._connectedUniverseId(instance_id)
|
|
13380
|
+
}));
|
|
13381
|
+
}
|
|
13382
|
+
// Monetization APIs address a universe, which Studio reports as game.GameId.
|
|
13383
|
+
async _connectedUniverseId(instance_id) {
|
|
13384
|
+
let info;
|
|
13385
|
+
try {
|
|
13386
|
+
info = await this._callSingle("/api/place-info", {}, void 0, instance_id);
|
|
13387
|
+
} catch (error) {
|
|
13388
|
+
if (error instanceof RoutingFailure) {
|
|
13389
|
+
throw new RoutingFailure({
|
|
13390
|
+
...error.routingError,
|
|
13391
|
+
message: `${error.routingError.message} Pass universe_id to skip Studio.`
|
|
13392
|
+
});
|
|
13393
|
+
}
|
|
13394
|
+
throw error;
|
|
13395
|
+
}
|
|
13396
|
+
return typeof info.gameId === "number" ? info.gameId : 0;
|
|
13397
|
+
}
|
|
11809
13398
|
async searchObjects(query, searchType = "name", propertyName, instance_id) {
|
|
11810
13399
|
const response = await this._callSingle("/api/search-objects", {
|
|
11811
13400
|
query,
|
|
@@ -11866,9 +13455,9 @@ var RobloxStudioTools = class {
|
|
|
11866
13455
|
]
|
|
11867
13456
|
};
|
|
11868
13457
|
}
|
|
11869
|
-
async getProjectStructure(
|
|
13458
|
+
async getProjectStructure(path11, maxDepth, scriptsOnly, instance_id) {
|
|
11870
13459
|
const response = await this._callSingle("/api/project-structure", {
|
|
11871
|
-
path:
|
|
13460
|
+
path: path11,
|
|
11872
13461
|
maxDepth,
|
|
11873
13462
|
scriptsOnly
|
|
11874
13463
|
}, void 0, instance_id);
|
|
@@ -12029,7 +13618,7 @@ var RobloxStudioTools = class {
|
|
|
12029
13618
|
};
|
|
12030
13619
|
}
|
|
12031
13620
|
async setSelection(paths, mode, instance_id) {
|
|
12032
|
-
if (!Array.isArray(paths) || paths.some((
|
|
13621
|
+
if (!Array.isArray(paths) || paths.some((path11) => typeof path11 !== "string" || path11.length === 0)) {
|
|
12033
13622
|
throw new Error("selection paths must contain only non-empty instance paths");
|
|
12034
13623
|
}
|
|
12035
13624
|
const selectionMode = mode ?? "set";
|
|
@@ -12766,9 +14355,9 @@ var RobloxStudioTools = class {
|
|
|
12766
14355
|
const rawJson = mutable.raw_json;
|
|
12767
14356
|
if (typeof rawJson === "string") {
|
|
12768
14357
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
12769
|
-
const resolvedOutputPath =
|
|
12770
|
-
|
|
12771
|
-
|
|
14358
|
+
const resolvedOutputPath = path10.resolve(outputPath);
|
|
14359
|
+
fs5.mkdirSync(path10.dirname(resolvedOutputPath), { recursive: true });
|
|
14360
|
+
fs5.writeFileSync(resolvedOutputPath, rawJson, "utf8");
|
|
12772
14361
|
mutable.output_path = resolvedOutputPath;
|
|
12773
14362
|
}
|
|
12774
14363
|
delete mutable.raw_json;
|
|
@@ -12829,9 +14418,9 @@ var RobloxStudioTools = class {
|
|
|
12829
14418
|
const rawSnapshotBase64 = mutable.raw_snapshot_base64;
|
|
12830
14419
|
if (typeof rawSnapshotBase64 === "string") {
|
|
12831
14420
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
12832
|
-
const resolvedOutputPath =
|
|
12833
|
-
|
|
12834
|
-
|
|
14421
|
+
const resolvedOutputPath = path10.resolve(outputPath);
|
|
14422
|
+
fs5.mkdirSync(path10.dirname(resolvedOutputPath), { recursive: true });
|
|
14423
|
+
fs5.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
|
|
12835
14424
|
mutable.output_path = resolvedOutputPath;
|
|
12836
14425
|
}
|
|
12837
14426
|
delete mutable.raw_snapshot_base64;
|
|
@@ -12845,9 +14434,9 @@ var RobloxStudioTools = class {
|
|
|
12845
14434
|
});
|
|
12846
14435
|
}
|
|
12847
14436
|
if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
|
|
12848
|
-
const resolvedSummaryPath =
|
|
12849
|
-
|
|
12850
|
-
|
|
14437
|
+
const resolvedSummaryPath = path10.resolve(summaryOutputPath);
|
|
14438
|
+
fs5.mkdirSync(path10.dirname(resolvedSummaryPath), { recursive: true });
|
|
14439
|
+
fs5.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
|
|
12851
14440
|
mutable.summary_output_path = resolvedSummaryPath;
|
|
12852
14441
|
}
|
|
12853
14442
|
if (!includeComparisonIndex) {
|
|
@@ -12909,7 +14498,7 @@ var RobloxStudioTools = class {
|
|
|
12909
14498
|
return record.placeId !== void 0 && instance.placeId === record.placeId;
|
|
12910
14499
|
}
|
|
12911
14500
|
if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
|
|
12912
|
-
const expectedName =
|
|
14501
|
+
const expectedName = path10.basename(record.localPlaceFile);
|
|
12913
14502
|
return instance.placeName === expectedName || instance.dataModelName === expectedName;
|
|
12914
14503
|
}
|
|
12915
14504
|
return true;
|
|
@@ -12922,7 +14511,7 @@ var RobloxStudioTools = class {
|
|
|
12922
14511
|
await instanceManager.attachInstanceId(candidate, instance.instanceId);
|
|
12923
14512
|
}
|
|
12924
14513
|
async _deriveUniverseId(placeId) {
|
|
12925
|
-
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe
|
|
14514
|
+
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`, { signal: AbortSignal.timeout(3e4) });
|
|
12926
14515
|
if (!response.ok) {
|
|
12927
14516
|
const body = await response.text().catch(() => "");
|
|
12928
14517
|
throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
|
|
@@ -14240,11 +15829,11 @@ var RobloxStudioTools = class {
|
|
|
14240
15829
|
return this.resolveUploadedReferenceImageId(decalId, instance_id);
|
|
14241
15830
|
}
|
|
14242
15831
|
async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
|
|
14243
|
-
if (!
|
|
15832
|
+
if (!fs5.existsSync(filePath)) {
|
|
14244
15833
|
throw new Error(`File not found: ${filePath}`);
|
|
14245
15834
|
}
|
|
14246
|
-
const fileContent =
|
|
14247
|
-
const fileName =
|
|
15835
|
+
const fileContent = fs5.readFileSync(filePath);
|
|
15836
|
+
const fileName = path10.basename(filePath);
|
|
14248
15837
|
const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
|
|
14249
15838
|
const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
|
|
14250
15839
|
if (assetType === "Decal" && this.cookieClient.hasCookie()) {
|
|
@@ -14460,10 +16049,10 @@ var RobloxStudioTools = class {
|
|
|
14460
16049
|
return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
|
|
14461
16050
|
}
|
|
14462
16051
|
const bytes = Buffer.from(response.base64, "base64");
|
|
14463
|
-
const resolved =
|
|
16052
|
+
const resolved = path10.resolve(outputPath);
|
|
14464
16053
|
try {
|
|
14465
|
-
|
|
14466
|
-
|
|
16054
|
+
fs5.mkdirSync(path10.dirname(resolved), { recursive: true });
|
|
16055
|
+
fs5.writeFileSync(resolved, bytes);
|
|
14467
16056
|
} catch (err2) {
|
|
14468
16057
|
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
|
|
14469
16058
|
}
|
|
@@ -14478,7 +16067,7 @@ var RobloxStudioTools = class {
|
|
|
14478
16067
|
}]
|
|
14479
16068
|
};
|
|
14480
16069
|
}
|
|
14481
|
-
async importRbxm(source, parentPath, target, instance_id) {
|
|
16070
|
+
async importRbxm(source, parentPath, target, instance_id, signal) {
|
|
14482
16071
|
if (!source || typeof source !== "object") {
|
|
14483
16072
|
throw new Error("source is required for import_rbxm");
|
|
14484
16073
|
}
|
|
@@ -14493,59 +16082,18 @@ var RobloxStudioTools = class {
|
|
|
14493
16082
|
if (modes.length !== 1) {
|
|
14494
16083
|
throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
|
|
14495
16084
|
}
|
|
14496
|
-
let
|
|
14497
|
-
|
|
14498
|
-
|
|
14499
|
-
|
|
14500
|
-
|
|
14501
|
-
bytes = fs4.readFileSync(resolved);
|
|
14502
|
-
} catch (err2) {
|
|
14503
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err2.message}` }) }] };
|
|
14504
|
-
}
|
|
14505
|
-
sourceLabel = resolved;
|
|
14506
|
-
} else if (source.url !== void 0) {
|
|
14507
|
-
let parsedUrl;
|
|
14508
|
-
try {
|
|
14509
|
-
parsedUrl = new URL(source.url);
|
|
14510
|
-
} catch {
|
|
14511
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url is not a valid URL: ${source.url}` }) }] };
|
|
14512
|
-
}
|
|
14513
|
-
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
14514
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url must use http(s); got ${parsedUrl.protocol}` }) }] };
|
|
14515
|
-
}
|
|
14516
|
-
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
14517
|
-
try {
|
|
14518
|
-
const res = await fetch(source.url);
|
|
14519
|
-
if (!res.ok) {
|
|
14520
|
-
const snippet = (await res.text()).slice(0, 500);
|
|
14521
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} returned ${res.status}: ${snippet}` }) }] };
|
|
14522
|
-
}
|
|
14523
|
-
const claimed = Number(res.headers.get("content-length") ?? "0");
|
|
14524
|
-
if (claimed > MAX_IMPORT_BYTES) {
|
|
14525
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: content-length ${claimed} exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
14526
|
-
}
|
|
14527
|
-
const arr = await res.arrayBuffer();
|
|
14528
|
-
if (arr.byteLength > MAX_IMPORT_BYTES) {
|
|
14529
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: downloaded ${arr.byteLength} bytes exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
14530
|
-
}
|
|
14531
|
-
bytes = Buffer.from(arr);
|
|
14532
|
-
} catch (err2) {
|
|
14533
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} failed: ${err2.message}` }) }] };
|
|
14534
|
-
}
|
|
14535
|
-
sourceLabel = source.url;
|
|
14536
|
-
} else {
|
|
14537
|
-
try {
|
|
14538
|
-
bytes = Buffer.from(source.base64, "base64");
|
|
14539
|
-
} catch (err2) {
|
|
14540
|
-
return { content: [{ type: "text", text: JSON.stringify({ error: `base64 decode failed: ${err2.message}` }) }] };
|
|
14541
|
-
}
|
|
14542
|
-
sourceLabel = `base64(${bytes.length}B)`;
|
|
16085
|
+
let input;
|
|
16086
|
+
try {
|
|
16087
|
+
input = await readRbxmInput(source, parentPath, signal);
|
|
16088
|
+
} catch (error) {
|
|
16089
|
+
return this._textResult({ error: error instanceof Error ? error.message : String(error) });
|
|
14543
16090
|
}
|
|
16091
|
+
const { bytes, sourceLabel } = input;
|
|
14544
16092
|
const response = await this._callSingle("/api/import-rbxm", {
|
|
14545
16093
|
base64: bytes.toString("base64"),
|
|
14546
16094
|
parent_path: parentPath,
|
|
14547
16095
|
source_label: sourceLabel
|
|
14548
|
-
}, tgt, instance_id);
|
|
16096
|
+
}, tgt, instance_id, void 0, signal);
|
|
14549
16097
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
14550
16098
|
}
|
|
14551
16099
|
// Even native capture must wait while another call has fitted the simulator
|
|
@@ -14567,24 +16115,26 @@ var RobloxStudioTools = class {
|
|
|
14567
16115
|
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
14568
16116
|
let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
|
|
14569
16117
|
const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
|
|
16118
|
+
const studioFastPathUnavailable = studioFastPathMissing ? response.unavailable ?? response.error : void 0;
|
|
14570
16119
|
if (studioFastPathMissing) {
|
|
14571
16120
|
if (targetRole.startsWith("client-")) {
|
|
14572
16121
|
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
14573
16122
|
if (begin.error) {
|
|
14574
|
-
|
|
14575
|
-
}
|
|
14576
|
-
|
|
14577
|
-
|
|
16123
|
+
response = { error: begin.error };
|
|
16124
|
+
} else if (!begin.contentId) {
|
|
16125
|
+
response = { error: "Screenshot capture failed: no content id returned from client." };
|
|
16126
|
+
} else {
|
|
16127
|
+
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
14578
16128
|
}
|
|
14579
|
-
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
14580
16129
|
} else {
|
|
14581
16130
|
response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
|
|
14582
16131
|
}
|
|
16132
|
+
response.source ??= "CaptureService";
|
|
14583
16133
|
}
|
|
14584
16134
|
let hostNote = "";
|
|
14585
16135
|
let hostReason;
|
|
14586
16136
|
try {
|
|
14587
|
-
hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ?
|
|
16137
|
+
hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ? `Studio's ${response.source ?? "capture"} returned a blank (single-colour) frame` : void 0;
|
|
14588
16138
|
} catch (error) {
|
|
14589
16139
|
response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
|
|
14590
16140
|
hostReason = `Studio's capture failed (${response.error})`;
|
|
@@ -14605,7 +16155,7 @@ var RobloxStudioTools = class {
|
|
|
14605
16155
|
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
14606
16156
|
text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular solo_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
|
|
14607
16157
|
}
|
|
14608
|
-
return { success: false, error: text };
|
|
16158
|
+
return { success: false, error: text, studioFastPathUnavailable };
|
|
14609
16159
|
}
|
|
14610
16160
|
const w = response.width;
|
|
14611
16161
|
const h = response.height;
|
|
@@ -14659,7 +16209,9 @@ var RobloxStudioTools = class {
|
|
|
14659
16209
|
note,
|
|
14660
16210
|
data: buffer.toString("base64"),
|
|
14661
16211
|
mimeType,
|
|
14662
|
-
message
|
|
16212
|
+
message,
|
|
16213
|
+
source: response.source,
|
|
16214
|
+
studioFastPathUnavailable
|
|
14663
16215
|
};
|
|
14664
16216
|
}
|
|
14665
16217
|
_isUniformCaptureResponse(response) {
|
|
@@ -14681,6 +16233,19 @@ var RobloxStudioTools = class {
|
|
|
14681
16233
|
}
|
|
14682
16234
|
// Always restore the simulator, including failures and cached window grabs.
|
|
14683
16235
|
async _captureViewportFromHostWindow(instanceId, targetRole) {
|
|
16236
|
+
if (isHostCaptureDisabled()) {
|
|
16237
|
+
return { success: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
|
|
16238
|
+
}
|
|
16239
|
+
if (!isHostCaptureSupported()) {
|
|
16240
|
+
return { success: false, error: hostCaptureUnsupportedReason() };
|
|
16241
|
+
}
|
|
16242
|
+
if (this.hostWindowCapture === captureStudioWindow) {
|
|
16243
|
+
try {
|
|
16244
|
+
await prepareHostWindowCapture();
|
|
16245
|
+
} catch (error) {
|
|
16246
|
+
return { success: false, error: `could not prepare host capture: ${error instanceof Error ? error.message : String(error)}` };
|
|
16247
|
+
}
|
|
16248
|
+
}
|
|
14684
16249
|
let prepared;
|
|
14685
16250
|
let result;
|
|
14686
16251
|
try {
|
|
@@ -14734,9 +16299,12 @@ var RobloxStudioTools = class {
|
|
|
14734
16299
|
const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
|
|
14735
16300
|
const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
|
|
14736
16301
|
if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
|
|
14737
|
-
const grab = await this.hostWindowCapture(titleHint);
|
|
16302
|
+
const grab = await this.hostWindowCapture(titleHint, cached.identity);
|
|
14738
16303
|
if (!grab.ok)
|
|
14739
16304
|
return { success: false, error: grab.error };
|
|
16305
|
+
if (cached.identity !== void 0 && !sameHostWindowIdentity(grab.capture.identity, cached.identity)) {
|
|
16306
|
+
return { success: false, error: "the Studio window identity changed since locating the viewport; retry the capture" };
|
|
16307
|
+
}
|
|
14740
16308
|
if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
|
|
14741
16309
|
return {
|
|
14742
16310
|
success: true,
|
|
@@ -14772,21 +16340,25 @@ var RobloxStudioTools = class {
|
|
|
14772
16340
|
});
|
|
14773
16341
|
if ("error" in located)
|
|
14774
16342
|
return { success: false, error: located.error };
|
|
16343
|
+
const cleanGrab = await this.hostWindowCapture(titleHint, markerGrab.capture.identity);
|
|
16344
|
+
if (!cleanGrab.ok)
|
|
16345
|
+
return { success: false, error: cleanGrab.error };
|
|
16346
|
+
if (markerGrab.capture.identity !== void 0 && !sameHostWindowIdentity(cleanGrab.capture.identity, markerGrab.capture.identity)) {
|
|
16347
|
+
return { success: false, error: "the Studio window identity changed while locating the viewport; retry the capture" };
|
|
16348
|
+
}
|
|
16349
|
+
if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
|
|
16350
|
+
this.hostViewportRects.delete(cacheKey);
|
|
16351
|
+
return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
|
|
16352
|
+
}
|
|
14775
16353
|
this.hostViewportRects.set(cacheKey, {
|
|
14776
16354
|
rect: located.rect,
|
|
16355
|
+
identity: markerGrab.capture.identity,
|
|
14777
16356
|
windowWidth: markerGrab.capture.width,
|
|
14778
16357
|
windowHeight: markerGrab.capture.height,
|
|
14779
16358
|
viewportWidth,
|
|
14780
16359
|
viewportHeight,
|
|
14781
16360
|
cachedAt: Date.now()
|
|
14782
16361
|
});
|
|
14783
|
-
const cleanGrab = await this.hostWindowCapture(titleHint);
|
|
14784
|
-
if (!cleanGrab.ok)
|
|
14785
|
-
return { success: false, error: cleanGrab.error };
|
|
14786
|
-
if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
|
|
14787
|
-
this.hostViewportRects.delete(cacheKey);
|
|
14788
|
-
return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
|
|
14789
|
-
}
|
|
14790
16362
|
return {
|
|
14791
16363
|
success: true,
|
|
14792
16364
|
response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
|
|
@@ -14812,7 +16384,7 @@ var RobloxStudioTools = class {
|
|
|
14812
16384
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
14813
16385
|
const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
|
|
14814
16386
|
if (!capture.success) {
|
|
14815
|
-
return this._textResult({ error: capture.error });
|
|
16387
|
+
return this._textResult({ error: capture.error, studioFastPathUnavailable: capture.studioFastPathUnavailable });
|
|
14816
16388
|
}
|
|
14817
16389
|
return {
|
|
14818
16390
|
content: [
|
|
@@ -14824,7 +16396,9 @@ var RobloxStudioTools = class {
|
|
|
14824
16396
|
format: capture.format,
|
|
14825
16397
|
mimeType: capture.mimeType,
|
|
14826
16398
|
...capture.quality === void 0 ? {} : { quality: capture.quality },
|
|
14827
|
-
message: capture.message
|
|
16399
|
+
message: capture.message,
|
|
16400
|
+
source: capture.source,
|
|
16401
|
+
studioFastPathUnavailable: capture.studioFastPathUnavailable
|
|
14828
16402
|
})
|
|
14829
16403
|
},
|
|
14830
16404
|
{
|
|
@@ -14838,7 +16412,7 @@ var RobloxStudioTools = class {
|
|
|
14838
16412
|
};
|
|
14839
16413
|
|
|
14840
16414
|
// ../core/dist/proxy-bridge-service.js
|
|
14841
|
-
import { randomUUID as
|
|
16415
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
14842
16416
|
var PROXY_RESPONSE_GRACE_MS = 5e3;
|
|
14843
16417
|
function parseRequestStatus(value) {
|
|
14844
16418
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -14897,7 +16471,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14897
16471
|
super();
|
|
14898
16472
|
this.primaryBaseUrl = primaryBaseUrl;
|
|
14899
16473
|
this.authToken = authToken;
|
|
14900
|
-
this.proxyInstanceId =
|
|
16474
|
+
this.proxyInstanceId = randomUUID5();
|
|
14901
16475
|
this.initialRefresh = this.refreshTopology();
|
|
14902
16476
|
this.refreshTimer = setInterval(() => this.refreshTopology(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
|
|
14903
16477
|
}
|
|
@@ -14974,6 +16548,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14974
16548
|
const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
|
|
14975
16549
|
method: "POST",
|
|
14976
16550
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
16551
|
+
signal: AbortSignal.timeout(5e3),
|
|
14977
16552
|
body: JSON.stringify({ groupId, controllerInstanceId })
|
|
14978
16553
|
});
|
|
14979
16554
|
if (!response.ok) {
|
|
@@ -15004,6 +16579,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
15004
16579
|
const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
|
|
15005
16580
|
method: "POST",
|
|
15006
16581
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
16582
|
+
signal: AbortSignal.timeout(5e3),
|
|
15007
16583
|
body: JSON.stringify({ groupId })
|
|
15008
16584
|
});
|
|
15009
16585
|
if (response.status === 409) {
|
|
@@ -15035,6 +16611,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
15035
16611
|
const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
|
|
15036
16612
|
method: "POST",
|
|
15037
16613
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
16614
|
+
signal: AbortSignal.timeout(5e3),
|
|
15038
16615
|
body: JSON.stringify({ instanceId })
|
|
15039
16616
|
});
|
|
15040
16617
|
if (!response.ok) {
|
|
@@ -15064,7 +16641,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
15064
16641
|
}
|
|
15065
16642
|
async getRequestStatusEverywhere(requestId) {
|
|
15066
16643
|
const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
|
|
15067
|
-
headers: this.authHeaders()
|
|
16644
|
+
headers: this.authHeaders(),
|
|
16645
|
+
signal: AbortSignal.timeout(5e3)
|
|
15068
16646
|
});
|
|
15069
16647
|
if (!response.ok)
|
|
15070
16648
|
throw new Error(`Proxy request status failed (${response.status})`);
|
|
@@ -15079,7 +16657,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
15079
16657
|
return status;
|
|
15080
16658
|
}
|
|
15081
16659
|
async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal, operationId) {
|
|
15082
|
-
const requestId = operationId ??
|
|
16660
|
+
const requestId = operationId ?? randomUUID5();
|
|
15083
16661
|
const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
|
|
15084
16662
|
if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
|
|
15085
16663
|
throw new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details);
|
|
@@ -15369,8 +16947,8 @@ var BoundedStdioTransport = class {
|
|
|
15369
16947
|
this.onStreamError(error);
|
|
15370
16948
|
return Promise.reject(error);
|
|
15371
16949
|
}
|
|
15372
|
-
const { promise, resolve:
|
|
15373
|
-
const pending = { resolve:
|
|
16950
|
+
const { promise, resolve: resolve6, reject } = Promise.withResolvers();
|
|
16951
|
+
const pending = { resolve: resolve6, reject };
|
|
15374
16952
|
this.pendingSends.add(pending);
|
|
15375
16953
|
this.pendingWrites++;
|
|
15376
16954
|
try {
|
|
@@ -15382,7 +16960,7 @@ var BoundedStdioTransport = class {
|
|
|
15382
16960
|
});
|
|
15383
16961
|
if (writable) {
|
|
15384
16962
|
this.pendingSends.delete(pending);
|
|
15385
|
-
|
|
16963
|
+
resolve6();
|
|
15386
16964
|
} else if (!this.closed) {
|
|
15387
16965
|
this.outputBlocked = true;
|
|
15388
16966
|
this.input.pause();
|
|
@@ -15481,6 +17059,7 @@ var RobloxStudioMCPServer = class {
|
|
|
15481
17059
|
try {
|
|
15482
17060
|
const result = await listenWithRetry(candidateApp, host, basePort, 1);
|
|
15483
17061
|
const oldBridge = this.bridge;
|
|
17062
|
+
const oldTools = this.tools;
|
|
15484
17063
|
this.bridge = candidateBridge;
|
|
15485
17064
|
this.tools = candidateTools;
|
|
15486
17065
|
if (oldBridge instanceof ProxyBridgeService) {
|
|
@@ -15494,6 +17073,7 @@ var RobloxStudioMCPServer = class {
|
|
|
15494
17073
|
console.error(`Promoted from proxy to primary on port ${boundPort}`);
|
|
15495
17074
|
if (promotionInterval)
|
|
15496
17075
|
clearInterval(promotionInterval);
|
|
17076
|
+
await oldTools.dispose();
|
|
15497
17077
|
} catch {
|
|
15498
17078
|
await candidateApp.cleanup().catch(() => {
|
|
15499
17079
|
});
|
|
@@ -15558,6 +17138,7 @@ var RobloxStudioMCPServer = class {
|
|
|
15558
17138
|
});
|
|
15559
17139
|
if (httpHandle)
|
|
15560
17140
|
httpHandle.close();
|
|
17141
|
+
await this.tools.dispose();
|
|
15561
17142
|
process.exit(0);
|
|
15562
17143
|
};
|
|
15563
17144
|
process.on("SIGTERM", shutdown);
|
|
@@ -16974,7 +18555,7 @@ var TOOL_DEFINITIONS = [
|
|
|
16974
18555
|
includeAudio: {
|
|
16975
18556
|
type: "boolean",
|
|
16976
18557
|
default: true,
|
|
16977
|
-
description: "Return inline audio; needs asset:
|
|
18558
|
+
description: "Return inline audio; needs legacy-asset:manage and never writes files."
|
|
16978
18559
|
},
|
|
16979
18560
|
maxAudioPreviews: {
|
|
16980
18561
|
type: "number",
|
|
@@ -17050,6 +18631,72 @@ var TOOL_DEFINITIONS = [
|
|
|
17050
18631
|
}
|
|
17051
18632
|
}
|
|
17052
18633
|
},
|
|
18634
|
+
// === Monetization ===
|
|
18635
|
+
{
|
|
18636
|
+
name: "manage_monetization",
|
|
18637
|
+
category: "write",
|
|
18638
|
+
description: "Use to list, inspect, create, or update developer products and game passes.",
|
|
18639
|
+
inputSchema: {
|
|
18640
|
+
type: "object",
|
|
18641
|
+
properties: {
|
|
18642
|
+
action: {
|
|
18643
|
+
type: "string",
|
|
18644
|
+
enum: ["list", "get", "create", "update"],
|
|
18645
|
+
description: "Operation; create and update change live Roblox items."
|
|
18646
|
+
},
|
|
18647
|
+
kind: {
|
|
18648
|
+
type: "string",
|
|
18649
|
+
enum: ["developer_product", "game_pass"],
|
|
18650
|
+
description: "Item type."
|
|
18651
|
+
},
|
|
18652
|
+
universe_id: {
|
|
18653
|
+
type: "integer",
|
|
18654
|
+
minimum: 1,
|
|
18655
|
+
description: "Universe ID; defaults to the connected Studio place."
|
|
18656
|
+
},
|
|
18657
|
+
id: {
|
|
18658
|
+
type: "integer",
|
|
18659
|
+
minimum: 1,
|
|
18660
|
+
description: "Product or pass ID; required for get and update."
|
|
18661
|
+
},
|
|
18662
|
+
name: {
|
|
18663
|
+
type: "string",
|
|
18664
|
+
description: "Item name; required for create."
|
|
18665
|
+
},
|
|
18666
|
+
description: {
|
|
18667
|
+
type: "string",
|
|
18668
|
+
description: "Item description shown to buyers."
|
|
18669
|
+
},
|
|
18670
|
+
price: {
|
|
18671
|
+
type: "integer",
|
|
18672
|
+
minimum: 1,
|
|
18673
|
+
maximum: 1e9,
|
|
18674
|
+
description: "Default price in Robux."
|
|
18675
|
+
},
|
|
18676
|
+
for_sale: {
|
|
18677
|
+
type: "boolean",
|
|
18678
|
+
description: "Purchasable; create defaults to false."
|
|
18679
|
+
},
|
|
18680
|
+
managed_pricing: {
|
|
18681
|
+
type: "boolean",
|
|
18682
|
+
description: "Let Roblox price optimization set the price."
|
|
18683
|
+
},
|
|
18684
|
+
image_path: {
|
|
18685
|
+
type: "string",
|
|
18686
|
+
description: "Local PNG, JPEG, or BMP icon file."
|
|
18687
|
+
},
|
|
18688
|
+
page_token: {
|
|
18689
|
+
type: "string",
|
|
18690
|
+
description: "Prior list page token."
|
|
18691
|
+
},
|
|
18692
|
+
instance_id: {
|
|
18693
|
+
type: "string",
|
|
18694
|
+
description: "Studio process ID when ambiguous."
|
|
18695
|
+
}
|
|
18696
|
+
},
|
|
18697
|
+
required: ["action", "kind"]
|
|
18698
|
+
}
|
|
18699
|
+
},
|
|
17053
18700
|
// === Input Simulation ===
|
|
17054
18701
|
{
|
|
17055
18702
|
name: "simulate_mouse_input",
|
|
@@ -17219,7 +18866,7 @@ var TOOL_DEFINITIONS = [
|
|
|
17219
18866
|
properties: {
|
|
17220
18867
|
source: {
|
|
17221
18868
|
type: "object",
|
|
17222
|
-
description: "Exactly one path, URL, or base64 source;
|
|
18869
|
+
description: "Exactly one path, URL, or base64 source. All inputs are capped below 37.5 MiB to fit base64 plus routing metadata within transport limits; URL downloads have a 30-second deadline.",
|
|
17223
18870
|
properties: {
|
|
17224
18871
|
path: { type: "string", description: "Absolute local .rbxm path." },
|
|
17225
18872
|
url: { type: "string", description: "HTTP or HTTPS .rbxm URL." },
|
|
@@ -17348,10 +18995,10 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
|
|
|
17348
18995
|
|
|
17349
18996
|
// ../core/dist/install-plugin-helpers.js
|
|
17350
18997
|
var import_saxes = __toESM(require_saxes(), 1);
|
|
17351
|
-
import { existsSync as existsSync7, lstatSync, mkdirSync as
|
|
18998
|
+
import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync, rmSync as rmSync5, rmdirSync, statSync as statSync4, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
17352
18999
|
import { execSync } from "child_process";
|
|
17353
|
-
import { randomUUID as
|
|
17354
|
-
import { basename as basename3, join as
|
|
19000
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
19001
|
+
import { basename as basename3, join as join9 } from "path";
|
|
17355
19002
|
import { homedir as homedir4 } from "os";
|
|
17356
19003
|
import { isUtf8 } from "buffer";
|
|
17357
19004
|
var DEFAULT_PLUGIN_PORT = 58741;
|
|
@@ -17403,15 +19050,15 @@ function getPluginsFolder() {
|
|
|
17403
19050
|
if (process.env.MCP_PLUGINS_DIR)
|
|
17404
19051
|
return process.env.MCP_PLUGINS_DIR;
|
|
17405
19052
|
if (process.platform === "win32") {
|
|
17406
|
-
return
|
|
19053
|
+
return join9(process.env.LOCALAPPDATA || join9(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
|
|
17407
19054
|
}
|
|
17408
19055
|
if (isWSL()) {
|
|
17409
19056
|
const localAppData = getWindowsUserLocalAppData();
|
|
17410
19057
|
if (localAppData)
|
|
17411
|
-
return
|
|
19058
|
+
return join9(localAppData.linuxPath, "Roblox", "Plugins");
|
|
17412
19059
|
console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
|
|
17413
19060
|
}
|
|
17414
|
-
return
|
|
19061
|
+
return join9(homedir4(), "Documents", "Roblox", "Plugins");
|
|
17415
19062
|
}
|
|
17416
19063
|
var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
|
|
17417
19064
|
function pluginDirectorySettingPattern() {
|
|
@@ -17426,7 +19073,7 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
|
|
|
17426
19073
|
if (studioPluginsDirectory === "") {
|
|
17427
19074
|
throw new Error("studioPluginsDirectory must be a non-empty path.");
|
|
17428
19075
|
}
|
|
17429
|
-
const contents =
|
|
19076
|
+
const contents = readFileSync9(settingsPath, "utf8");
|
|
17430
19077
|
const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
|
|
17431
19078
|
if (matches.length !== 1) {
|
|
17432
19079
|
throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
|
|
@@ -17434,16 +19081,16 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
|
|
|
17434
19081
|
if (matches[0][2] !== STALE_TEST_PLUGINS_DIRECTORY)
|
|
17435
19082
|
return false;
|
|
17436
19083
|
const studioPath = escapeXmlText(studioPluginsDirectory);
|
|
17437
|
-
const updated = contents.replace(pluginDirectorySettingPattern(), (_match,
|
|
17438
|
-
const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${
|
|
19084
|
+
const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open4, _value, close) => `${open4}${studioPath}${close}`);
|
|
19085
|
+
const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID6()}.tmp`;
|
|
17439
19086
|
try {
|
|
17440
|
-
|
|
19087
|
+
writeFileSync5(temporaryPath, updated, {
|
|
17441
19088
|
encoding: "utf8",
|
|
17442
|
-
mode:
|
|
19089
|
+
mode: statSync4(settingsPath).mode
|
|
17443
19090
|
});
|
|
17444
19091
|
renameSync(temporaryPath, settingsPath);
|
|
17445
19092
|
} finally {
|
|
17446
|
-
|
|
19093
|
+
rmSync5(temporaryPath, { force: true });
|
|
17447
19094
|
}
|
|
17448
19095
|
return true;
|
|
17449
19096
|
}
|
|
@@ -17454,16 +19101,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
|
|
|
17454
19101
|
let defaultPluginsFolder;
|
|
17455
19102
|
let studioPluginsDirectory;
|
|
17456
19103
|
if (process.platform === "win32") {
|
|
17457
|
-
const localAppData = process.env.LOCALAPPDATA ||
|
|
17458
|
-
defaultPluginsFolder =
|
|
17459
|
-
settingsPath =
|
|
19104
|
+
const localAppData = process.env.LOCALAPPDATA || join9(homedir4(), "AppData", "Local");
|
|
19105
|
+
defaultPluginsFolder = join9(localAppData, "Roblox", "Plugins");
|
|
19106
|
+
settingsPath = join9(localAppData, "Roblox", "GlobalSettings_13.xml");
|
|
17460
19107
|
studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
|
|
17461
19108
|
} else if (isWSL()) {
|
|
17462
19109
|
const localAppData = getWindowsUserLocalAppData();
|
|
17463
19110
|
if (!localAppData)
|
|
17464
19111
|
return;
|
|
17465
|
-
defaultPluginsFolder =
|
|
17466
|
-
settingsPath =
|
|
19112
|
+
defaultPluginsFolder = join9(localAppData.linuxPath, "Roblox", "Plugins");
|
|
19113
|
+
settingsPath = join9(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
|
|
17467
19114
|
studioPluginsDirectory = [
|
|
17468
19115
|
localAppData.windowsPath.replace(/[\\/]+$/u, ""),
|
|
17469
19116
|
"Roblox",
|
|
@@ -17479,12 +19126,12 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
|
|
|
17479
19126
|
}
|
|
17480
19127
|
}
|
|
17481
19128
|
function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
|
|
17482
|
-
const otherDest =
|
|
19129
|
+
const otherDest = join9(pluginsFolder, otherAssetName);
|
|
17483
19130
|
if (!existsSync7(otherDest))
|
|
17484
19131
|
return;
|
|
17485
19132
|
if (replace) {
|
|
17486
19133
|
try {
|
|
17487
|
-
|
|
19134
|
+
unlinkSync3(otherDest);
|
|
17488
19135
|
log(`Removed conflicting ${otherAssetName}.`);
|
|
17489
19136
|
} catch (err2) {
|
|
17490
19137
|
warn(`[install-plugin] Could not remove ${otherDest}: ${err2}. Continuing.`);
|
|
@@ -17498,18 +19145,18 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
|
|
|
17498
19145
|
`);
|
|
17499
19146
|
}
|
|
17500
19147
|
var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
|
|
17501
|
-
function
|
|
19148
|
+
function errorCode2(error) {
|
|
17502
19149
|
return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
17503
19150
|
}
|
|
17504
19151
|
function pluginInstallLockError(lockPath, reason) {
|
|
17505
19152
|
return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
|
|
17506
19153
|
}
|
|
17507
19154
|
function readPluginInstallLockOwner(lockPath) {
|
|
17508
|
-
const ownerPath =
|
|
17509
|
-
if (!
|
|
19155
|
+
const ownerPath = join9(lockPath, "owner.json");
|
|
19156
|
+
if (!lstatSync2(lockPath).isDirectory() || !lstatSync2(ownerPath).isFile()) {
|
|
17510
19157
|
throw new Error("Lock and owner metadata must be a directory and regular file");
|
|
17511
19158
|
}
|
|
17512
|
-
const owner = JSON.parse(
|
|
19159
|
+
const owner = JSON.parse(readFileSync9(ownerPath, "utf8"));
|
|
17513
19160
|
if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
|
|
17514
19161
|
throw new Error("Invalid lock owner metadata");
|
|
17515
19162
|
}
|
|
@@ -17520,9 +19167,9 @@ function assertPluginInstallOwnerExited(lockPath, owner) {
|
|
|
17520
19167
|
try {
|
|
17521
19168
|
process.kill(owner.pid, 0);
|
|
17522
19169
|
} catch (error) {
|
|
17523
|
-
if (
|
|
19170
|
+
if (errorCode2(error) === "ESRCH")
|
|
17524
19171
|
return;
|
|
17525
|
-
throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${
|
|
19172
|
+
throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode2(error) ?? error})`);
|
|
17526
19173
|
}
|
|
17527
19174
|
}
|
|
17528
19175
|
throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
|
|
@@ -17535,13 +19182,13 @@ function recoverPluginInstallLock(lockPath, warn) {
|
|
|
17535
19182
|
throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
|
|
17536
19183
|
}
|
|
17537
19184
|
assertPluginInstallOwnerExited(lockPath, owner);
|
|
17538
|
-
const claimPath =
|
|
19185
|
+
const claimPath = join9(lockPath, `recovery-${owner.token}`);
|
|
17539
19186
|
try {
|
|
17540
|
-
|
|
19187
|
+
mkdirSync5(claimPath);
|
|
17541
19188
|
} catch (error) {
|
|
17542
|
-
throw pluginInstallLockError(lockPath,
|
|
19189
|
+
throw pluginInstallLockError(lockPath, errorCode2(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
|
|
17543
19190
|
}
|
|
17544
|
-
const abandonedPath = `${lockPath}.${
|
|
19191
|
+
const abandonedPath = `${lockPath}.${randomUUID6()}.abandoned`;
|
|
17545
19192
|
let moved = false;
|
|
17546
19193
|
try {
|
|
17547
19194
|
let currentOwner;
|
|
@@ -17561,49 +19208,49 @@ function recoverPluginInstallLock(lockPath, warn) {
|
|
|
17561
19208
|
try {
|
|
17562
19209
|
rmdirSync(claimPath);
|
|
17563
19210
|
} catch (error) {
|
|
17564
|
-
if (
|
|
19211
|
+
if (errorCode2(error) !== "ENOENT") {
|
|
17565
19212
|
warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
|
|
17566
19213
|
}
|
|
17567
19214
|
}
|
|
17568
19215
|
}
|
|
17569
19216
|
}
|
|
17570
19217
|
try {
|
|
17571
|
-
|
|
19218
|
+
rmSync5(abandonedPath, { recursive: true, force: true });
|
|
17572
19219
|
} catch (error) {
|
|
17573
19220
|
warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
|
|
17574
19221
|
}
|
|
17575
19222
|
}
|
|
17576
19223
|
function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
17577
|
-
const lockPath =
|
|
17578
|
-
const ownerPath =
|
|
19224
|
+
const lockPath = join9(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
|
|
19225
|
+
const ownerPath = join9(lockPath, "owner.json");
|
|
17579
19226
|
const owner = {
|
|
17580
19227
|
pid: process.pid,
|
|
17581
|
-
token:
|
|
19228
|
+
token: randomUUID6(),
|
|
17582
19229
|
createdAt: Date.now()
|
|
17583
19230
|
};
|
|
17584
19231
|
try {
|
|
17585
|
-
|
|
19232
|
+
mkdirSync5(lockPath);
|
|
17586
19233
|
} catch (error) {
|
|
17587
|
-
if (
|
|
19234
|
+
if (errorCode2(error) !== "EEXIST")
|
|
17588
19235
|
throw error;
|
|
17589
19236
|
recoverPluginInstallLock(lockPath, warn);
|
|
17590
19237
|
try {
|
|
17591
|
-
|
|
19238
|
+
mkdirSync5(lockPath);
|
|
17592
19239
|
} catch (retryError) {
|
|
17593
|
-
if (
|
|
19240
|
+
if (errorCode2(retryError) !== "EEXIST")
|
|
17594
19241
|
throw retryError;
|
|
17595
19242
|
throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
|
|
17596
19243
|
}
|
|
17597
19244
|
}
|
|
17598
19245
|
try {
|
|
17599
|
-
|
|
19246
|
+
writeFileSync5(ownerPath, `${JSON.stringify(owner)}
|
|
17600
19247
|
`, {
|
|
17601
19248
|
encoding: "utf8",
|
|
17602
19249
|
flag: "wx"
|
|
17603
19250
|
});
|
|
17604
19251
|
} catch (error) {
|
|
17605
19252
|
try {
|
|
17606
|
-
|
|
19253
|
+
rmSync5(lockPath, { recursive: true, force: true });
|
|
17607
19254
|
} catch (cleanupError) {
|
|
17608
19255
|
warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
|
|
17609
19256
|
}
|
|
@@ -17613,10 +19260,10 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
17613
19260
|
try {
|
|
17614
19261
|
const currentOwner = readPluginInstallLockOwner(lockPath);
|
|
17615
19262
|
if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
|
|
17616
|
-
|
|
19263
|
+
rmSync5(lockPath, { recursive: true, force: true });
|
|
17617
19264
|
}
|
|
17618
19265
|
} catch (error) {
|
|
17619
|
-
if (
|
|
19266
|
+
if (errorCode2(error) !== "ENOENT") {
|
|
17620
19267
|
warn(`[install-plugin] Could not release install lock ${lockPath}: ${error}`);
|
|
17621
19268
|
}
|
|
17622
19269
|
}
|
|
@@ -17707,9 +19354,9 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
|
|
|
17707
19354
|
function installedFileMatches(source, destination) {
|
|
17708
19355
|
if (!existsSync7(destination))
|
|
17709
19356
|
return false;
|
|
17710
|
-
if (!
|
|
19357
|
+
if (!lstatSync2(destination).isFile())
|
|
17711
19358
|
return false;
|
|
17712
|
-
const installed =
|
|
19359
|
+
const installed = readFileSync9(destination);
|
|
17713
19360
|
return installed.length === source.length && installed.equals(source);
|
|
17714
19361
|
}
|
|
17715
19362
|
function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
|
|
@@ -17720,22 +19367,22 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
|
|
|
17720
19367
|
}
|
|
17721
19368
|
const configured = configurePluginAssetForPort(source, rawPort);
|
|
17722
19369
|
assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
|
|
17723
|
-
|
|
17724
|
-
const destination =
|
|
19370
|
+
mkdirSync5(pluginsFolder, { recursive: true });
|
|
19371
|
+
const destination = join9(pluginsFolder, assetName);
|
|
17725
19372
|
const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
|
|
17726
19373
|
try {
|
|
17727
19374
|
let installed = false;
|
|
17728
19375
|
if (!installedFileMatches(configured, destination)) {
|
|
17729
|
-
const staged =
|
|
19376
|
+
const staged = join9(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID6()}.tmp`);
|
|
17730
19377
|
try {
|
|
17731
|
-
|
|
19378
|
+
writeFileSync5(staged, configured, { flag: "wx" });
|
|
17732
19379
|
renameSync(staged, destination);
|
|
17733
19380
|
installed = true;
|
|
17734
19381
|
} finally {
|
|
17735
19382
|
try {
|
|
17736
|
-
|
|
19383
|
+
unlinkSync3(staged);
|
|
17737
19384
|
} catch (error) {
|
|
17738
|
-
if (
|
|
19385
|
+
if (errorCode2(error) !== "ENOENT") {
|
|
17739
19386
|
warn(`[install-plugin] Could not remove staging file ${staged}: ${error}`);
|
|
17740
19387
|
}
|
|
17741
19388
|
}
|
|
@@ -17755,175 +19402,156 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
|
|
|
17755
19402
|
}
|
|
17756
19403
|
}
|
|
17757
19404
|
|
|
17758
|
-
//
|
|
17759
|
-
import {
|
|
17760
|
-
|
|
17761
|
-
// src/install-plugin.ts
|
|
17762
|
-
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
17763
|
-
import { dirname as dirname5, join as join7 } from "path";
|
|
19405
|
+
// ../core/dist/plugin-installer.js
|
|
19406
|
+
import { existsSync as existsSync8, readFileSync as readFileSync10 } from "fs";
|
|
19407
|
+
import { dirname as dirname5, join as join10 } from "path";
|
|
17764
19408
|
import { fileURLToPath } from "url";
|
|
17765
19409
|
import { get } from "https";
|
|
17766
19410
|
var REPO = "chrrxs/robloxstudio-mcp";
|
|
17767
|
-
var ASSET_NAME = "MCPPlugin.rbxmx";
|
|
17768
|
-
var OTHER_VARIANT = "MCPInspectorPlugin.rbxmx";
|
|
17769
19411
|
var TIMEOUT_MS = 3e4;
|
|
17770
19412
|
var MAX_REDIRECTS = 5;
|
|
17771
19413
|
var MAX_ASSET_BYTES = 64 * 1024 * 1024;
|
|
17772
|
-
function
|
|
17773
|
-
|
|
17774
|
-
|
|
17775
|
-
|
|
17776
|
-
|
|
17777
|
-
|
|
19414
|
+
function createPluginInstaller(moduleUrl, variant) {
|
|
19415
|
+
const currentDir = dirname5(fileURLToPath(moduleUrl));
|
|
19416
|
+
const ASSET_NAME = variant === "main" ? "MCPPlugin.rbxmx" : "MCPInspectorPlugin.rbxmx";
|
|
19417
|
+
const OTHER_VARIANT = variant === "main" ? "MCPInspectorPlugin.rbxmx" : "MCPPlugin.rbxmx";
|
|
19418
|
+
const userAgent = variant === "main" ? "robloxstudio-mcp" : "robloxstudio-mcp-inspector";
|
|
19419
|
+
const buildCommand = variant === "main" ? "build:plugin" : "build:plugin:inspector";
|
|
19420
|
+
function httpsGet(url) {
|
|
19421
|
+
return new Promise((resolve6, reject) => {
|
|
19422
|
+
const req = get(url, { headers: { "User-Agent": userAgent } }, resolve6);
|
|
19423
|
+
req.on("error", reject);
|
|
19424
|
+
req.setTimeout(TIMEOUT_MS, () => {
|
|
19425
|
+
req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
|
|
19426
|
+
});
|
|
17778
19427
|
});
|
|
17779
|
-
});
|
|
17780
|
-
}
|
|
17781
|
-
async function download(url, redirects = 0) {
|
|
17782
|
-
const res = await httpsGet(url);
|
|
17783
|
-
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
17784
|
-
if (redirects >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
17785
|
-
const location = res.headers.location;
|
|
17786
|
-
if (!location) throw new Error("Redirect with no location header");
|
|
17787
|
-
for await (const _chunk of res) {
|
|
17788
|
-
}
|
|
17789
|
-
return download(location, redirects + 1);
|
|
17790
19428
|
}
|
|
17791
|
-
|
|
17792
|
-
|
|
17793
|
-
|
|
17794
|
-
|
|
17795
|
-
|
|
17796
|
-
|
|
17797
|
-
|
|
17798
|
-
|
|
17799
|
-
|
|
17800
|
-
|
|
17801
|
-
|
|
17802
|
-
|
|
17803
|
-
);
|
|
19429
|
+
async function download(url, redirects = 0) {
|
|
19430
|
+
const res = await httpsGet(url);
|
|
19431
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
19432
|
+
if (redirects >= MAX_REDIRECTS)
|
|
19433
|
+
throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
19434
|
+
const location = res.headers.location;
|
|
19435
|
+
if (!location)
|
|
19436
|
+
throw new Error("Redirect with no location header");
|
|
19437
|
+
for await (const chunk of res) {
|
|
19438
|
+
void chunk;
|
|
19439
|
+
}
|
|
19440
|
+
return download(location, redirects + 1);
|
|
17804
19441
|
}
|
|
17805
|
-
|
|
17806
|
-
|
|
17807
|
-
|
|
17808
|
-
|
|
17809
|
-
|
|
17810
|
-
|
|
17811
|
-
|
|
17812
|
-
|
|
19442
|
+
if (res.statusCode !== 200) {
|
|
19443
|
+
throw new Error(`Download failed: HTTP ${res.statusCode}`);
|
|
19444
|
+
}
|
|
19445
|
+
const chunks = [];
|
|
19446
|
+
let totalBytes = 0;
|
|
19447
|
+
for await (const chunk of res) {
|
|
19448
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
19449
|
+
totalBytes += bytes.length;
|
|
19450
|
+
if (totalBytes > MAX_ASSET_BYTES) {
|
|
19451
|
+
res.destroy();
|
|
19452
|
+
throw new Error(`${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`);
|
|
19453
|
+
}
|
|
19454
|
+
chunks.push(bytes);
|
|
19455
|
+
}
|
|
19456
|
+
return Buffer.concat(chunks, totalBytes);
|
|
17813
19457
|
}
|
|
17814
|
-
|
|
17815
|
-
|
|
17816
|
-
|
|
19458
|
+
async function fetchJson(url) {
|
|
19459
|
+
const res = await httpsGet(url);
|
|
19460
|
+
if (res.statusCode !== 200) {
|
|
19461
|
+
throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
|
|
19462
|
+
}
|
|
19463
|
+
const chunks = [];
|
|
19464
|
+
for await (const chunk of res) {
|
|
19465
|
+
chunks.push(chunk);
|
|
19466
|
+
}
|
|
19467
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
17817
19468
|
}
|
|
17818
|
-
|
|
17819
|
-
|
|
17820
|
-
|
|
17821
|
-
|
|
17822
|
-
|
|
17823
|
-
|
|
17824
|
-
);
|
|
17825
|
-
if (!prerelease) {
|
|
17826
|
-
throw new Error(`No prerelease found with ${ASSET_NAME}`);
|
|
19469
|
+
function bundledAssetPath() {
|
|
19470
|
+
const candidates = [
|
|
19471
|
+
join10(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
19472
|
+
join10(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
19473
|
+
];
|
|
19474
|
+
return candidates.find((candidate) => existsSync8(candidate)) ?? null;
|
|
17827
19475
|
}
|
|
17828
|
-
|
|
17829
|
-
|
|
17830
|
-
|
|
17831
|
-
|
|
17832
|
-
const candidates = [
|
|
17833
|
-
join7(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
17834
|
-
join7(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
17835
|
-
];
|
|
17836
|
-
return candidates.find((candidate) => existsSync8(candidate)) ?? null;
|
|
17837
|
-
}
|
|
17838
|
-
function resolvePluginAssetPath(sourcePath) {
|
|
17839
|
-
if (sourcePath === void 0) return bundledAssetPath();
|
|
17840
|
-
return existsSync8(sourcePath) ? sourcePath : null;
|
|
17841
|
-
}
|
|
17842
|
-
function packageVersion() {
|
|
17843
|
-
const currentDir = dirname5(fileURLToPath(import.meta.url));
|
|
17844
|
-
const pkg = JSON.parse(readFileSync8(join7(currentDir, "..", "package.json"), "utf8"));
|
|
17845
|
-
if (!pkg.version) {
|
|
17846
|
-
throw new Error("Package version not found");
|
|
19476
|
+
function resolvePluginAssetPath(sourcePath) {
|
|
19477
|
+
if (sourcePath === void 0)
|
|
19478
|
+
return bundledAssetPath();
|
|
19479
|
+
return existsSync8(sourcePath) ? sourcePath : null;
|
|
17847
19480
|
}
|
|
17848
|
-
|
|
17849
|
-
|
|
17850
|
-
|
|
17851
|
-
|
|
17852
|
-
|
|
17853
|
-
|
|
17854
|
-
const sourcePath = resolvePluginAssetPath(options.sourcePath);
|
|
17855
|
-
if (!sourcePath) {
|
|
17856
|
-
throw new Error(
|
|
17857
|
-
`Bundled ${ASSET_NAME} not found. Run npm run build:plugin in this worktree first.`
|
|
17858
|
-
);
|
|
17859
|
-
}
|
|
17860
|
-
const result = await installPluginAsset({
|
|
17861
|
-
pluginsFolder: getPluginsFolder(),
|
|
17862
|
-
assetName: ASSET_NAME,
|
|
17863
|
-
otherAssetName: OTHER_VARIANT,
|
|
17864
|
-
source: readFileSync8(sourcePath),
|
|
17865
|
-
expectedVersion: packageVersion(),
|
|
17866
|
-
expectedVariant: "main",
|
|
17867
|
-
replaceVariant,
|
|
17868
|
-
log,
|
|
17869
|
-
warn
|
|
17870
|
-
});
|
|
17871
|
-
if (result.installed) {
|
|
17872
|
-
log(`Installed ${ASSET_NAME} to ${result.destination}`);
|
|
19481
|
+
function packageVersion() {
|
|
19482
|
+
const pkg = JSON.parse(readFileSync10(join10(currentDir, "..", "package.json"), "utf8"));
|
|
19483
|
+
if (!pkg.version) {
|
|
19484
|
+
throw new Error("Package version not found");
|
|
19485
|
+
}
|
|
19486
|
+
return pkg.version;
|
|
17873
19487
|
}
|
|
17874
|
-
|
|
17875
|
-
|
|
17876
|
-
const dev = options.dev ?? process.argv.includes("--dev");
|
|
17877
|
-
const replaceVariant = options.replaceVariant ?? true;
|
|
17878
|
-
const log = options.log ?? console.log;
|
|
17879
|
-
const warn = options.warn ?? console.warn;
|
|
17880
|
-
const bundled = resolvePluginAssetPath(options.sourcePath);
|
|
17881
|
-
if (options.sourcePath !== void 0 && !bundled) {
|
|
17882
|
-
throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
|
|
17883
|
-
}
|
|
17884
|
-
if (bundled) {
|
|
17885
|
-
const result2 = await installPluginAsset({
|
|
19488
|
+
function installSource(source, options) {
|
|
19489
|
+
return installPluginAsset({
|
|
17886
19490
|
pluginsFolder: getPluginsFolder(),
|
|
17887
19491
|
assetName: ASSET_NAME,
|
|
17888
19492
|
otherAssetName: OTHER_VARIANT,
|
|
17889
|
-
source
|
|
19493
|
+
source,
|
|
17890
19494
|
expectedVersion: packageVersion(),
|
|
17891
|
-
expectedVariant:
|
|
17892
|
-
replaceVariant,
|
|
17893
|
-
log,
|
|
17894
|
-
warn
|
|
19495
|
+
expectedVariant: variant,
|
|
19496
|
+
replaceVariant: options.replaceVariant ?? true,
|
|
19497
|
+
log: options.log ?? console.log,
|
|
19498
|
+
warn: options.warn ?? console.warn
|
|
17895
19499
|
});
|
|
17896
|
-
|
|
17897
|
-
|
|
19500
|
+
}
|
|
19501
|
+
async function installBundledPlugin2(options = {}) {
|
|
19502
|
+
const log = options.log ?? console.log;
|
|
19503
|
+
const sourcePath = resolvePluginAssetPath(options.sourcePath);
|
|
19504
|
+
if (!sourcePath) {
|
|
19505
|
+
throw new Error(`Bundled ${ASSET_NAME} not found. Run npm run ${buildCommand} in this worktree first.`);
|
|
19506
|
+
}
|
|
19507
|
+
const result = installSource(readFileSync10(sourcePath), options);
|
|
19508
|
+
if (result.installed) {
|
|
19509
|
+
log(`Installed ${ASSET_NAME} to ${result.destination}`);
|
|
19510
|
+
}
|
|
19511
|
+
}
|
|
19512
|
+
async function installPlugin2(options = {}, dev = false) {
|
|
19513
|
+
const log = options.log ?? console.log;
|
|
19514
|
+
const bundled = resolvePluginAssetPath(options.sourcePath);
|
|
19515
|
+
if (options.sourcePath !== void 0 && !bundled) {
|
|
19516
|
+
throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
|
|
19517
|
+
}
|
|
19518
|
+
if (bundled) {
|
|
19519
|
+
const result2 = installSource(readFileSync10(bundled), options);
|
|
19520
|
+
if (result2.installed) {
|
|
19521
|
+
log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
|
|
19522
|
+
} else {
|
|
19523
|
+
log(`${ASSET_NAME} already installed.`);
|
|
19524
|
+
}
|
|
19525
|
+
return;
|
|
19526
|
+
}
|
|
19527
|
+
log(dev ? "Fetching matching dev release..." : "Fetching matching release...");
|
|
19528
|
+
const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/v${encodeURIComponent(packageVersion())}`);
|
|
19529
|
+
const asset = release.assets?.find((a) => a.name === ASSET_NAME);
|
|
19530
|
+
if (!asset) {
|
|
19531
|
+
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
19532
|
+
}
|
|
19533
|
+
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
19534
|
+
const downloaded = await download(asset.browser_download_url);
|
|
19535
|
+
const result = installSource(downloaded, options);
|
|
19536
|
+
if (result.installed) {
|
|
19537
|
+
log(`Installed to ${result.destination}`);
|
|
17898
19538
|
} else {
|
|
17899
19539
|
log(`${ASSET_NAME} already installed.`);
|
|
17900
19540
|
}
|
|
17901
|
-
return;
|
|
17902
|
-
}
|
|
17903
|
-
log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
|
|
17904
|
-
const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
17905
|
-
const asset = release.assets?.find((a) => a.name === ASSET_NAME);
|
|
17906
|
-
if (!asset) {
|
|
17907
|
-
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
17908
|
-
}
|
|
17909
|
-
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
17910
|
-
const downloaded = await download(asset.browser_download_url);
|
|
17911
|
-
const result = await installPluginAsset({
|
|
17912
|
-
pluginsFolder: getPluginsFolder(),
|
|
17913
|
-
assetName: ASSET_NAME,
|
|
17914
|
-
otherAssetName: OTHER_VARIANT,
|
|
17915
|
-
source: downloaded,
|
|
17916
|
-
expectedVersion: release.tag_name.replace(/^v/, ""),
|
|
17917
|
-
expectedVariant: "main",
|
|
17918
|
-
replaceVariant,
|
|
17919
|
-
log,
|
|
17920
|
-
warn
|
|
17921
|
-
});
|
|
17922
|
-
if (result.installed) {
|
|
17923
|
-
log(`Installed to ${result.destination}`);
|
|
17924
|
-
} else {
|
|
17925
|
-
log(`${ASSET_NAME} already installed.`);
|
|
17926
19541
|
}
|
|
19542
|
+
return { installBundledPlugin: installBundledPlugin2, installPlugin: installPlugin2 };
|
|
19543
|
+
}
|
|
19544
|
+
|
|
19545
|
+
// src/index.ts
|
|
19546
|
+
import { createRequire } from "module";
|
|
19547
|
+
|
|
19548
|
+
// src/install-plugin.ts
|
|
19549
|
+
var installer = createPluginInstaller(import.meta.url, "main");
|
|
19550
|
+
async function installBundledPlugin(options = {}) {
|
|
19551
|
+
await installer.installBundledPlugin(options);
|
|
19552
|
+
}
|
|
19553
|
+
async function installPlugin(options = {}) {
|
|
19554
|
+
await installer.installPlugin(options, options.dev ?? process.argv.includes("--dev"));
|
|
17927
19555
|
}
|
|
17928
19556
|
|
|
17929
19557
|
// src/index.ts
|