@unbound-app/cli 0.7.3 → 0.8.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.mjs +290 -58
- package/package.json +2 -2
- package/templates/plugin/package.json +0 -1
- package/templates/plugin/rollup.config.mjs +20 -4
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cli, command } from "cleye";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT } from "@unbound-app/debugger-protocol";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
6
|
import { z } from "zod";
|
|
4
7
|
import { existsSync, mkdirSync, openAsBlob, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
8
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
9
|
import { ControllerClient } from "@unbound-app/debugger-protocol/controller";
|
|
7
|
-
import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT } from "@unbound-app/debugger-protocol";
|
|
8
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { glob } from "node:fs/promises";
|
|
11
11
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
12
|
import { homedir } from "node:os";
|
|
@@ -37,7 +37,7 @@ async function readStdin() {
|
|
|
37
37
|
//#region src/lib/config.ts
|
|
38
38
|
/** The filename walked up the directory tree to locate a workspace root. */
|
|
39
39
|
const CONFIG_FILENAME = "unbound.config.json";
|
|
40
|
-
const schema$
|
|
40
|
+
const schema$4 = z.object({
|
|
41
41
|
$schema: z.string().optional(),
|
|
42
42
|
addons: z.array(z.string()).default(["plugins/*", "themes/*"]),
|
|
43
43
|
build: z.string().default("bun run build"),
|
|
@@ -53,13 +53,13 @@ const schema$2 = z.object({
|
|
|
53
53
|
function loadConfig(cwd = process.cwd()) {
|
|
54
54
|
const found = findConfigFile(cwd);
|
|
55
55
|
if (!found) return {
|
|
56
|
-
config: schema$
|
|
56
|
+
config: schema$4.parse({}),
|
|
57
57
|
dir: resolve(cwd),
|
|
58
58
|
bare: true
|
|
59
59
|
};
|
|
60
60
|
const raw = JSON.parse(readFileSync(found, "utf8"));
|
|
61
61
|
return {
|
|
62
|
-
config: schema$
|
|
62
|
+
config: schema$4.parse(raw),
|
|
63
63
|
dir: dirname(found),
|
|
64
64
|
bare: false
|
|
65
65
|
};
|
|
@@ -188,7 +188,12 @@ function toCleyeCommand(definition) {
|
|
|
188
188
|
name: definition.name,
|
|
189
189
|
description: definition.description,
|
|
190
190
|
parameters: positionalNames.map((name) => `[${name}]`),
|
|
191
|
-
flags
|
|
191
|
+
flags,
|
|
192
|
+
help: definition.surfaces.cli === "mcp" ? { usage: [
|
|
193
|
+
"ubd mcp",
|
|
194
|
+
definition.name,
|
|
195
|
+
"[flags...]"
|
|
196
|
+
].concat(positionalNames.map((name) => `[${name}]`)).join(" ") } : void 0
|
|
192
197
|
}, async (parsed) => {
|
|
193
198
|
const { json, ...flagInput } = parsed.flags;
|
|
194
199
|
const input = { ...flagInput };
|
|
@@ -280,6 +285,8 @@ const apiErrorCodeSchema = z.enum([
|
|
|
280
285
|
"last_owner",
|
|
281
286
|
"github_not_linked",
|
|
282
287
|
"github_no_push_access",
|
|
288
|
+
"sync_conflict",
|
|
289
|
+
"sync_document_too_large",
|
|
283
290
|
"internal_error"
|
|
284
291
|
]);
|
|
285
292
|
/** The body of every non-2xx JSON response from `/api/v1/*`. */
|
|
@@ -302,6 +309,13 @@ var ApiRequestError = class extends Error {
|
|
|
302
309
|
this.name = "ApiRequestError";
|
|
303
310
|
}
|
|
304
311
|
};
|
|
312
|
+
/** A `304 Not Modified` answer to a conditional request: the copy the caller holds is current. */
|
|
313
|
+
var NotModifiedError = class extends Error {
|
|
314
|
+
constructor() {
|
|
315
|
+
super("Not modified.");
|
|
316
|
+
this.name = "NotModifiedError";
|
|
317
|
+
}
|
|
318
|
+
};
|
|
305
319
|
function buildUrl(baseUrl, path, params, query) {
|
|
306
320
|
const url = new URL(buildPath(path, params), baseUrl);
|
|
307
321
|
for (const [name, value] of Object.entries(query ?? {})) {
|
|
@@ -326,7 +340,7 @@ async function toRequestError(response) {
|
|
|
326
340
|
* @description Creates a typed client for the Unbound web API. Routes, bodies and responses are
|
|
327
341
|
* checked against the API map exported by `@unbound-app/web/api`; responses are parsed with the
|
|
328
342
|
* route's contract schema and `{ error: { code, message } }` bodies are thrown as
|
|
329
|
-
* {@link ApiRequestError}.
|
|
343
|
+
* {@link ApiRequestError}. A `304` to a conditional request is thrown as {@link NotModifiedError}.
|
|
330
344
|
* @template Api The API map, `Api` from `@unbound-app/web/api`.
|
|
331
345
|
* @param options The API origin and headers sent with every request (e.g. credentials).
|
|
332
346
|
* @returns A `request(route, options)` function.
|
|
@@ -345,6 +359,7 @@ function createApiClient(options) {
|
|
|
345
359
|
headers,
|
|
346
360
|
body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
|
|
347
361
|
});
|
|
362
|
+
if (response.status === 304) throw new NotModifiedError();
|
|
348
363
|
if (!response.ok) throw await toRequestError(response);
|
|
349
364
|
return init.response.parse(await response.json());
|
|
350
365
|
};
|
|
@@ -427,13 +442,18 @@ function hasBuildScript(addonDir) {
|
|
|
427
442
|
* stdout and stderr are inherited so the build's own output surfaces live.
|
|
428
443
|
* @param addon The addon to build.
|
|
429
444
|
* @param buildCommand The shell command to run in the addon directory.
|
|
445
|
+
* @param env Variables added to the build's environment.
|
|
430
446
|
* @returns Whether the build succeeded (always `true` for static addons).
|
|
431
447
|
*/
|
|
432
|
-
function buildAddon(addon, buildCommand) {
|
|
448
|
+
function buildAddon(addon, buildCommand, env = {}) {
|
|
433
449
|
if (addon.static) return Promise.resolve(true);
|
|
434
450
|
return new Promise((resolve) => {
|
|
435
451
|
const child = spawn(buildCommand, {
|
|
436
452
|
cwd: addon.dir,
|
|
453
|
+
env: {
|
|
454
|
+
...process.env,
|
|
455
|
+
...env
|
|
456
|
+
},
|
|
437
457
|
shell: true,
|
|
438
458
|
stdio: "inherit"
|
|
439
459
|
});
|
|
@@ -608,7 +628,7 @@ function getBundleName(addon, main) {
|
|
|
608
628
|
const apiUrlSchema = z.url({ protocol: /^https?$/ }).transform((url) => new URL(url).origin).default("https://unbound.rip");
|
|
609
629
|
//#endregion
|
|
610
630
|
//#region src/lib/env.ts
|
|
611
|
-
const schema$
|
|
631
|
+
const schema$3 = z.object({
|
|
612
632
|
/** The origin of the Unbound web API that `ubd login` and `ubd publish` talk to. */
|
|
613
633
|
UNBOUND_API_URL: apiUrlSchema,
|
|
614
634
|
/** An API key for CI; when set, `ubd publish` sends it instead of the stored login. */
|
|
@@ -622,7 +642,7 @@ const schema$1 = z.object({
|
|
|
622
642
|
* @returns The validated environment.
|
|
623
643
|
*/
|
|
624
644
|
function parseEnv(source = process.env) {
|
|
625
|
-
const parsed = schema$
|
|
645
|
+
const parsed = schema$3.safeParse(source);
|
|
626
646
|
if (!parsed.success) throw new Error(`Invalid environment:\n${z.prettifyError(parsed.error)}`);
|
|
627
647
|
return parsed.data;
|
|
628
648
|
}
|
|
@@ -637,7 +657,7 @@ function getCredentialsPath(env) {
|
|
|
637
657
|
}
|
|
638
658
|
//#endregion
|
|
639
659
|
//#region src/lib/credentials.ts
|
|
640
|
-
const schema = z.object({
|
|
660
|
+
const schema$2 = z.object({
|
|
641
661
|
/** The API origin the session belongs to; it is never sent to any other origin. */
|
|
642
662
|
apiUrl: z.url(),
|
|
643
663
|
/** The Better Auth session token from the device authorization flow, sent as a bearer token. */
|
|
@@ -650,7 +670,7 @@ const schema = z.object({
|
|
|
650
670
|
*/
|
|
651
671
|
function readCredentials(path) {
|
|
652
672
|
if (!existsSync(path)) return;
|
|
653
|
-
const parsed = schema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
673
|
+
const parsed = schema$2.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
654
674
|
if (!parsed.success) throw new Error(`Invalid credentials in ${path}:\n${z.prettifyError(parsed.error)}`);
|
|
655
675
|
return parsed.data;
|
|
656
676
|
}
|
|
@@ -742,7 +762,7 @@ const publishCommand = {
|
|
|
742
762
|
name: "publish",
|
|
743
763
|
description: "Build an addon and submit it for review on Unbound. Authenticates with UNBOUND_API_KEY when set, otherwise with the `ubd login` session.",
|
|
744
764
|
surfaces: {
|
|
745
|
-
cli:
|
|
765
|
+
cli: "root",
|
|
746
766
|
mcp: false
|
|
747
767
|
},
|
|
748
768
|
schema: z.object({
|
|
@@ -1096,7 +1116,7 @@ const createCommand = {
|
|
|
1096
1116
|
name: "create",
|
|
1097
1117
|
description: "Scaffold a new Unbound addon or addon workspace. Prompts for anything left unset, so a fully-flagged invocation runs without prompts for use in CI.",
|
|
1098
1118
|
surfaces: {
|
|
1099
|
-
cli:
|
|
1119
|
+
cli: "root",
|
|
1100
1120
|
mcp: false
|
|
1101
1121
|
},
|
|
1102
1122
|
schema: z.object({
|
|
@@ -1148,7 +1168,7 @@ const logoutCommand = {
|
|
|
1148
1168
|
name: "logout",
|
|
1149
1169
|
description: "Delete the credentials `ubd login` stored.",
|
|
1150
1170
|
surfaces: {
|
|
1151
|
-
cli:
|
|
1171
|
+
cli: "root",
|
|
1152
1172
|
mcp: false
|
|
1153
1173
|
},
|
|
1154
1174
|
schema: z.object({}),
|
|
@@ -1172,12 +1192,130 @@ const logoutCommand = {
|
|
|
1172
1192
|
}
|
|
1173
1193
|
};
|
|
1174
1194
|
//#endregion
|
|
1195
|
+
//#region src/lib/format.ts
|
|
1196
|
+
/**
|
|
1197
|
+
* @description Renders an {@link EvalResult} into the human-readable transcript both surfaces print:
|
|
1198
|
+
* captured console output first, then the value or error, each under its own header.
|
|
1199
|
+
* @param result The device's eval result.
|
|
1200
|
+
* @returns The formatted transcript.
|
|
1201
|
+
*/
|
|
1202
|
+
function formatResult(result) {
|
|
1203
|
+
const parts = [];
|
|
1204
|
+
if (result.logs?.length) parts.push("--- console ---", result.logs.map(formatLog).join("\n"));
|
|
1205
|
+
if (result.ok) parts.push("--- result ---", result.value ?? "undefined");
|
|
1206
|
+
else parts.push("--- error ---", result.error ?? "Unknown error.");
|
|
1207
|
+
return parts.join("\n");
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* @description Renders a single {@link LogMessage} as a level-tagged line for the console transcript.
|
|
1211
|
+
* @param log The captured log line.
|
|
1212
|
+
* @returns The `[level] message` line.
|
|
1213
|
+
*/
|
|
1214
|
+
function formatLog(log) {
|
|
1215
|
+
return `[${log.level === 3 ? "error" : log.level === 2 ? "warn" : "log"}] ${log.message}`;
|
|
1216
|
+
}
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/lib/device.ts
|
|
1219
|
+
/**
|
|
1220
|
+
* @description Serialises a function into device code that calls it with Unbound's global and the given arguments.
|
|
1221
|
+
* @template A The function's arguments after `unbound`.
|
|
1222
|
+
* @template R The function's return type.
|
|
1223
|
+
* @param fn The function to run on the device. It must not close over anything outside its own body.
|
|
1224
|
+
* @param args The arguments to pass after `unbound`, sent as JSON.
|
|
1225
|
+
* @returns The JavaScript to evaluate.
|
|
1226
|
+
*/
|
|
1227
|
+
function toDeviceCode(fn, ...args) {
|
|
1228
|
+
return `(${fn.toString()})(unbound, ${args.map((arg) => JSON.stringify(arg)).join(", ")})`;
|
|
1229
|
+
}
|
|
1230
|
+
/** The answer every device command gives when the bridge has no device. */
|
|
1231
|
+
const NOT_CONNECTED = {
|
|
1232
|
+
ok: false,
|
|
1233
|
+
text: "No device is connected to the debugger. Launch the app so its debugger dials the bridge, then retry."
|
|
1234
|
+
};
|
|
1235
|
+
/**
|
|
1236
|
+
* @description Waits briefly for a device, then evaluates code on it.
|
|
1237
|
+
* @param client The bridge controller.
|
|
1238
|
+
* @param code The JavaScript to evaluate on the device.
|
|
1239
|
+
* @returns The device's result, or `undefined` when no device connected in time.
|
|
1240
|
+
*/
|
|
1241
|
+
async function evaluateOnDevice(client, code) {
|
|
1242
|
+
await waitForDevice(client);
|
|
1243
|
+
if (!client.isDeviceConnected) return;
|
|
1244
|
+
return client.evaluate(code);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* @description Evaluates code on the device and renders the result as a command's output.
|
|
1248
|
+
* @param client The bridge controller.
|
|
1249
|
+
* @param code The JavaScript to evaluate on the device.
|
|
1250
|
+
* @returns The formatted transcript with the raw result as data, or {@link NOT_CONNECTED}.
|
|
1251
|
+
*/
|
|
1252
|
+
async function runOnDevice(client, code) {
|
|
1253
|
+
const result = await evaluateOnDevice(client, code);
|
|
1254
|
+
if (!result) return NOT_CONNECTED;
|
|
1255
|
+
return {
|
|
1256
|
+
ok: result.ok,
|
|
1257
|
+
text: formatResult(result),
|
|
1258
|
+
data: result
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/commands/reload.ts
|
|
1263
|
+
/** The global the running bundle is tagged with, so the reloaded one can be told apart. */
|
|
1264
|
+
const TOKEN_KEY = "__UBD_RELOAD__";
|
|
1265
|
+
/** How often the reload command probes for the fresh bundle. */
|
|
1266
|
+
const RELOAD_POLL_MS = 500;
|
|
1267
|
+
const schema$1 = z.object({
|
|
1268
|
+
timeout: z.number().default(6e4).describe("How long to wait for the reloaded app to reconnect, in milliseconds."),
|
|
1269
|
+
port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.")
|
|
1270
|
+
});
|
|
1271
|
+
function tagAndReload(unbound, key, token) {
|
|
1272
|
+
Reflect.set(globalThis, key, token);
|
|
1273
|
+
setTimeout(() => void unbound.native.reload(), 100);
|
|
1274
|
+
return "reloading";
|
|
1275
|
+
}
|
|
1276
|
+
function bundleFreshness(_unbound, key, token) {
|
|
1277
|
+
return Reflect.get(globalThis, key) === token ? "stale" : "fresh";
|
|
1278
|
+
}
|
|
1279
|
+
const reloadCommand = {
|
|
1280
|
+
name: "reload",
|
|
1281
|
+
description: "Reload the JavaScript bundle on the connected device and wait until the fresh bundle reconnects to the debugger. The fresh bundle is told apart by a token the old one carried.",
|
|
1282
|
+
surfaces: {
|
|
1283
|
+
cli: "root",
|
|
1284
|
+
mcp: true
|
|
1285
|
+
},
|
|
1286
|
+
schema: schema$1,
|
|
1287
|
+
async handler({ timeout }, { client }) {
|
|
1288
|
+
const token = crypto.randomUUID();
|
|
1289
|
+
const started = Date.now();
|
|
1290
|
+
const result = await evaluateOnDevice(client, toDeviceCode(tagAndReload, TOKEN_KEY, token));
|
|
1291
|
+
if (!result) return NOT_CONNECTED;
|
|
1292
|
+
if (!result.ok) return {
|
|
1293
|
+
ok: false,
|
|
1294
|
+
text: formatResult(result)
|
|
1295
|
+
};
|
|
1296
|
+
while (Date.now() - started < timeout) {
|
|
1297
|
+
await Bun.sleep(RELOAD_POLL_MS);
|
|
1298
|
+
await waitForDevice(client, RELOAD_POLL_MS);
|
|
1299
|
+
if (!client.isDeviceConnected) continue;
|
|
1300
|
+
const probe = await client.evaluate(toDeviceCode(bundleFreshness, TOKEN_KEY, token));
|
|
1301
|
+
if (probe.ok && probe.value === "fresh") return {
|
|
1302
|
+
ok: true,
|
|
1303
|
+
text: `Reloaded in ${Date.now() - started}ms.`
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
ok: false,
|
|
1308
|
+
text: `The app did not reconnect within ${timeout}ms of reloading.`
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
//#endregion
|
|
1175
1313
|
//#region src/commands/build.ts
|
|
1176
1314
|
const buildCommand = {
|
|
1177
1315
|
name: "build",
|
|
1178
1316
|
description: "Build every addon in the workspace once, reporting per-addon success and failure.",
|
|
1179
1317
|
surfaces: {
|
|
1180
|
-
cli:
|
|
1318
|
+
cli: "root",
|
|
1181
1319
|
mcp: false
|
|
1182
1320
|
},
|
|
1183
1321
|
schema: z.object({}),
|
|
@@ -1265,7 +1403,7 @@ const loginCommand = {
|
|
|
1265
1403
|
name: "login",
|
|
1266
1404
|
description: "Log in to Unbound by approving a code on the website, so `ubd publish` can submit addons. Not needed in CI when UNBOUND_API_KEY is set.",
|
|
1267
1405
|
surfaces: {
|
|
1268
|
-
cli:
|
|
1406
|
+
cli: "root",
|
|
1269
1407
|
mcp: false
|
|
1270
1408
|
},
|
|
1271
1409
|
schema: z.object({}),
|
|
@@ -1296,35 +1434,74 @@ function printCode(code) {
|
|
|
1296
1434
|
process.stdout.write(`Open ${code.verificationUriComplete} and confirm the code ${code.userCode}.\n(Or go to ${code.verificationUri} and enter it.)\nWaiting for approval…\n`);
|
|
1297
1435
|
}
|
|
1298
1436
|
//#endregion
|
|
1299
|
-
//#region src/lib/
|
|
1437
|
+
//#region src/lib/navigation.ts
|
|
1300
1438
|
/**
|
|
1301
|
-
* @description
|
|
1302
|
-
*
|
|
1303
|
-
* @param
|
|
1304
|
-
* @
|
|
1439
|
+
* @description Opens a settings screen from anywhere in the app, through Discord's root navigation ref.
|
|
1440
|
+
* @param unbound Unbound's global on the device.
|
|
1441
|
+
* @param name A route key, or a name matching one ignoring case, underscores and `UNBOUND_` (`Marketplace` → `UNBOUND_MARKETPLACE`).
|
|
1442
|
+
* @param params Route parameters for the screen.
|
|
1443
|
+
* @returns The route key it opened. Throws, listing Unbound's screens, when nothing matches.
|
|
1305
1444
|
*/
|
|
1306
|
-
function
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1445
|
+
function openScreen(unbound, name, params) {
|
|
1446
|
+
const { SETTING_RENDERER_CONFIG } = unbound.metro.findByProps("SETTING_RENDERER_CONFIG");
|
|
1447
|
+
const { getRootNavigationRef } = unbound.metro.findByProps("getRootNavigationRef");
|
|
1448
|
+
function normalize(key) {
|
|
1449
|
+
return key.toLowerCase().replace(/^unbound_/, "").replace(/[^a-z0-9]/g, "");
|
|
1450
|
+
}
|
|
1451
|
+
const keys = Object.keys(SETTING_RENDERER_CONFIG);
|
|
1452
|
+
const own = keys.filter((key) => key.startsWith("UNBOUND_"));
|
|
1453
|
+
const wanted = normalize(name);
|
|
1454
|
+
const route = keys.includes(name) ? name : own.find((key) => normalize(key) === wanted) ?? keys.find((key) => normalize(key) === wanted);
|
|
1455
|
+
if (!route) throw new Error(`No settings screen matches ${name}. Unbound screens: ${own.join(", ")}`);
|
|
1456
|
+
getRootNavigationRef().navigate("settings", {
|
|
1457
|
+
screen: route,
|
|
1458
|
+
params
|
|
1459
|
+
});
|
|
1460
|
+
return route;
|
|
1312
1461
|
}
|
|
1313
1462
|
/**
|
|
1314
|
-
* @description
|
|
1315
|
-
* @param
|
|
1316
|
-
* @returns The
|
|
1463
|
+
* @description Reads the focused route and the stack of route names leading to it.
|
|
1464
|
+
* @param unbound Unbound's global on the device.
|
|
1465
|
+
* @returns The route as a JSON string of {@link CurrentRoute}.
|
|
1317
1466
|
*/
|
|
1318
|
-
function
|
|
1319
|
-
|
|
1467
|
+
function currentRoute(unbound) {
|
|
1468
|
+
const ref = unbound.metro.findByProps("getRootNavigationRef").getRootNavigationRef();
|
|
1469
|
+
const path = [];
|
|
1470
|
+
let state = ref.getRootState();
|
|
1471
|
+
while (state) {
|
|
1472
|
+
const route = state.routes[state.index ?? state.routes.length - 1];
|
|
1473
|
+
if (!route) break;
|
|
1474
|
+
path.push(route.name);
|
|
1475
|
+
state = route.state;
|
|
1476
|
+
}
|
|
1477
|
+
const { name, params } = ref.getCurrentRoute();
|
|
1478
|
+
return JSON.stringify({
|
|
1479
|
+
name,
|
|
1480
|
+
params: params ?? {},
|
|
1481
|
+
path
|
|
1482
|
+
});
|
|
1320
1483
|
}
|
|
1321
1484
|
//#endregion
|
|
1485
|
+
//#region src/commands/route.ts
|
|
1486
|
+
const routeCommand = {
|
|
1487
|
+
name: "route",
|
|
1488
|
+
description: "Print the focused navigation route on the connected device: its name, its params, and the navigator path leading to it.",
|
|
1489
|
+
surfaces: {
|
|
1490
|
+
cli: "mcp",
|
|
1491
|
+
mcp: true
|
|
1492
|
+
},
|
|
1493
|
+
schema: z.object({ port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.") }),
|
|
1494
|
+
handler(_input, { client }) {
|
|
1495
|
+
return runOnDevice(client, toDeviceCode(currentRoute));
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
//#endregion
|
|
1322
1499
|
//#region src/commands/eval.ts
|
|
1323
1500
|
const evalCommand = {
|
|
1324
1501
|
name: "eval",
|
|
1325
1502
|
description: "Evaluate JavaScript inside the running Unbound client on the connected device and return its result plus any console output the code produced. Use this to inspect the live runtime: read globals (e.g. `unbound.metro`), call APIs, and probe Discord internals. The last expression is the return value; `console.log`/`warn`/`error` are captured separately.",
|
|
1326
1503
|
surfaces: {
|
|
1327
|
-
cli:
|
|
1504
|
+
cli: "root",
|
|
1328
1505
|
mcp: true
|
|
1329
1506
|
},
|
|
1330
1507
|
schema: z.object({
|
|
@@ -1332,18 +1509,43 @@ const evalCommand = {
|
|
|
1332
1509
|
port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.")
|
|
1333
1510
|
}),
|
|
1334
1511
|
cli: { positionals: ["code"] },
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1512
|
+
handler({ code }, { client }) {
|
|
1513
|
+
return runOnDevice(client, code);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/commands/open.ts
|
|
1518
|
+
const schema = z.object({
|
|
1519
|
+
screen: z.string().min(1).describe("The settings screen to open: a `Screens` key such as `Marketplace` or `Plugins`, or any `SETTING_RENDERER_CONFIG` route key."),
|
|
1520
|
+
params: z.string().default("{}").describe("Route parameters for the screen, as a JSON object, e.g. `{\"kind\":\"theme\"}`."),
|
|
1521
|
+
port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.")
|
|
1522
|
+
});
|
|
1523
|
+
const paramsSchema = z.record(z.string(), z.union([
|
|
1524
|
+
z.string(),
|
|
1525
|
+
z.number(),
|
|
1526
|
+
z.boolean(),
|
|
1527
|
+
z.null()
|
|
1528
|
+
]));
|
|
1529
|
+
const openCommand = {
|
|
1530
|
+
name: "open",
|
|
1531
|
+
description: "Open an Unbound (or Discord) settings screen on the connected device from anywhere in the app, through the root navigation ref. Prints the route key it opened.",
|
|
1532
|
+
surfaces: {
|
|
1533
|
+
cli: "mcp",
|
|
1534
|
+
mcp: true
|
|
1535
|
+
},
|
|
1536
|
+
schema,
|
|
1537
|
+
cli: { positionals: ["screen"] },
|
|
1538
|
+
async handler({ screen, params }, { client }) {
|
|
1539
|
+
let parsed;
|
|
1540
|
+
try {
|
|
1541
|
+
parsed = paramsSchema.parse(JSON.parse(params));
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
return {
|
|
1544
|
+
ok: false,
|
|
1545
|
+
text: `params must be a JSON object of plain values: ${toError(error).message}`
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
return runOnDevice(client, toDeviceCode(openScreen, screen, parsed));
|
|
1347
1549
|
}
|
|
1348
1550
|
};
|
|
1349
1551
|
//#endregion
|
|
@@ -1409,6 +1611,7 @@ function readBuiltAddon(addon) {
|
|
|
1409
1611
|
}
|
|
1410
1612
|
//#endregion
|
|
1411
1613
|
//#region src/lib/dev-server.ts
|
|
1614
|
+
const DEV_BUILD_ENV = { UNBOUND_HOT: "1" };
|
|
1412
1615
|
/** How long file changes inside one addon settle before that addon rebuilds. */
|
|
1413
1616
|
const DEBOUNCE_MS = 150;
|
|
1414
1617
|
/**
|
|
@@ -1463,7 +1666,7 @@ var DevServer = class {
|
|
|
1463
1666
|
}
|
|
1464
1667
|
async rebuild(addon) {
|
|
1465
1668
|
if (!addon.static) process.stdout.write(`Building ${addon.id}…\n`);
|
|
1466
|
-
if (!await buildAddon(addon, this.resolved.config.build)) {
|
|
1669
|
+
if (!await buildAddon(addon, this.resolved.config.build, DEV_BUILD_ENV)) {
|
|
1467
1670
|
process.stderr.write(`Failed to build ${addon.id}.\n`);
|
|
1468
1671
|
return;
|
|
1469
1672
|
}
|
|
@@ -1509,12 +1712,15 @@ function toBuiltAddon(addon) {
|
|
|
1509
1712
|
*/
|
|
1510
1713
|
const commands = [
|
|
1511
1714
|
evalCommand,
|
|
1715
|
+
openCommand,
|
|
1716
|
+
routeCommand,
|
|
1717
|
+
reloadCommand,
|
|
1512
1718
|
buildCommand,
|
|
1513
1719
|
{
|
|
1514
1720
|
name: "dev",
|
|
1515
1721
|
description: "Watch every addon in the workspace and rebuild only the addon whose files change, reloading it in the running client after each successful build. Runs until interrupted.",
|
|
1516
1722
|
surfaces: {
|
|
1517
|
-
cli:
|
|
1723
|
+
cli: "root",
|
|
1518
1724
|
mcp: false
|
|
1519
1725
|
},
|
|
1520
1726
|
schema: z.object({ port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.") }),
|
|
@@ -1535,10 +1741,11 @@ const commands = [
|
|
|
1535
1741
|
];
|
|
1536
1742
|
//#endregion
|
|
1537
1743
|
//#region src/commands/mcp.ts
|
|
1744
|
+
/** The agent-facing tools the CLI lists under `ubd mcp` instead of at the root. */
|
|
1745
|
+
const tools = commands.filter((definition) => definition.surfaces.cli === "mcp");
|
|
1538
1746
|
/**
|
|
1539
1747
|
* @description The `ubd mcp` command: a stdio MCP server exposing every registry entry opted into
|
|
1540
|
-
* `surfaces.mcp`, each riding a single long-lived controller connection to the bridge.
|
|
1541
|
-
* relocated debugger MCP, now registry-driven.
|
|
1748
|
+
* `surfaces.mcp`, each riding a single long-lived controller connection to the bridge.
|
|
1542
1749
|
*/
|
|
1543
1750
|
const mcpCommand = command({
|
|
1544
1751
|
name: "mcp",
|
|
@@ -1547,7 +1754,8 @@ const mcpCommand = command({
|
|
|
1547
1754
|
type: Number,
|
|
1548
1755
|
description: "The port the debugger bridge listens on.",
|
|
1549
1756
|
default: DEFAULT_BRIDGE_PORT
|
|
1550
|
-
} }
|
|
1757
|
+
} },
|
|
1758
|
+
help: { usage: ["ubd mcp [flags]", ...tools.map((tool) => `ubd mcp ${tool.name} --help`)] }
|
|
1551
1759
|
}, async (parsed) => {
|
|
1552
1760
|
const context = createContext(parsed.flags.port);
|
|
1553
1761
|
const server = new McpServer({
|
|
@@ -1561,6 +1769,24 @@ const mcpCommand = command({
|
|
|
1561
1769
|
const transport = new StdioServerTransport();
|
|
1562
1770
|
await server.connect(transport);
|
|
1563
1771
|
});
|
|
1772
|
+
/**
|
|
1773
|
+
* @description Whether a name is one of the tools run as `ubd mcp <tool>`.
|
|
1774
|
+
* @param name The argument after `mcp`.
|
|
1775
|
+
* @returns Whether `ubd mcp <name>` runs a tool instead of the server.
|
|
1776
|
+
*/
|
|
1777
|
+
function isMcpTool(name) {
|
|
1778
|
+
return tools.some((tool) => tool.name === name);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* @description Runs one MCP tool from the command line, as `ubd mcp <tool> [args]`.
|
|
1782
|
+
* @param argv The arguments after `mcp`, starting with the tool name.
|
|
1783
|
+
*/
|
|
1784
|
+
function runMcpTool(argv) {
|
|
1785
|
+
cli({
|
|
1786
|
+
name: "ubd",
|
|
1787
|
+
commands: tools.map(toCleyeCommand)
|
|
1788
|
+
}, void 0, argv);
|
|
1789
|
+
}
|
|
1564
1790
|
//#endregion
|
|
1565
1791
|
//#region src/lib/menu.ts
|
|
1566
1792
|
/** Concise, menu-friendly summaries keyed by command name; the registry descriptions are tuned for
|
|
@@ -1593,7 +1819,8 @@ const ORDER = [
|
|
|
1593
1819
|
* @returns The menu as a single printable string.
|
|
1594
1820
|
*/
|
|
1595
1821
|
function renderMenu() {
|
|
1596
|
-
const
|
|
1822
|
+
const roots = commands.filter((command) => command.surfaces.cli === "root");
|
|
1823
|
+
const names = /* @__PURE__ */ new Set([...roots.map((command) => command.name), "mcp"]);
|
|
1597
1824
|
const ordered = [...ORDER.filter((name) => names.has(name)), ...[...names].filter((name) => !ORDER.includes(name))];
|
|
1598
1825
|
const width = Math.max(...ordered.map((name) => name.length));
|
|
1599
1826
|
const rows = ordered.map((name) => {
|
|
@@ -1616,10 +1843,15 @@ function renderMenu() {
|
|
|
1616
1843
|
}
|
|
1617
1844
|
//#endregion
|
|
1618
1845
|
//#region src/index.ts
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1846
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
1847
|
+
if (first === "mcp" && isMcpTool(rest[0])) runMcpTool(rest);
|
|
1848
|
+
else {
|
|
1849
|
+
const roots = commands.filter((definition) => definition.surfaces.cli === "root");
|
|
1850
|
+
const argv = cli({
|
|
1851
|
+
name: "ubd",
|
|
1852
|
+
commands: [...roots.map(toCleyeCommand), mcpCommand]
|
|
1853
|
+
});
|
|
1854
|
+
if (!argv.command && argv._.length === 0 && !argv.flags.help && !argv.flags.version) process.stdout.write(`${renderMenu()}\n`);
|
|
1855
|
+
}
|
|
1624
1856
|
//#endregion
|
|
1625
1857
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unbound-app/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The `ubd` command line for the Unbound client: evaluate on-device code and serve the debugger as an MCP.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mario P.",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@clack/prompts": "^1.7.0",
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
|
-
"@unbound-app/debugger-protocol": "0.
|
|
31
|
+
"@unbound-app/debugger-protocol": "0.8.0",
|
|
32
32
|
"better-auth": "1.7.10",
|
|
33
33
|
"cleye": "^1.3.4",
|
|
34
34
|
"picocolors": "^1.1.1",
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
|
2
2
|
import { swc } from 'rollup-plugin-swc3';
|
|
3
3
|
import json from '@rollup/plugin-json';
|
|
4
|
-
import iife from 'rollup-plugin-iife';
|
|
5
4
|
|
|
6
5
|
const globals = {
|
|
7
6
|
'@unbound-app/api': 'window.unbound',
|
|
8
|
-
'
|
|
7
|
+
// The bare name reaches the React Unbound passes the bundle, which keeps components' state on hot refresh.
|
|
8
|
+
'react': 'React',
|
|
9
9
|
'react-native': 'window.ReactNative',
|
|
10
10
|
'react-native-reanimated': 'window.unbound.metro.common.Reanimated',
|
|
11
11
|
'@react-native-clipboard/clipboard': 'window.unbound.metro.common.Clipboard',
|
|
12
12
|
'moment': 'window.unbound.metro.common.Moment',
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
const hot = process.env.UNBOUND_HOT === '1';
|
|
16
|
+
|
|
17
|
+
function keepIifeUnnamedForUnbound(level, log, handler) {
|
|
18
|
+
if (log.code !== 'MISSING_NAME_OPTION_FOR_IIFE_EXPORT') {
|
|
19
|
+
handler(level, log);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** @type {import('rollup').RollupOptions} */
|
|
16
24
|
export default {
|
|
17
25
|
input: 'index.tsx',
|
|
18
26
|
external: Object.keys(globals),
|
|
19
|
-
plugins: [
|
|
27
|
+
plugins: [
|
|
28
|
+
nodeResolve(),
|
|
29
|
+
json(),
|
|
30
|
+
swc({
|
|
31
|
+
tsconfig: false,
|
|
32
|
+
jsc: { transform: { react: { refresh: hot, development: hot } } },
|
|
33
|
+
}),
|
|
34
|
+
],
|
|
35
|
+
onLog: keepIifeUnnamedForUnbound,
|
|
20
36
|
output: {
|
|
21
37
|
dir: 'dist',
|
|
22
|
-
format: '
|
|
38
|
+
format: 'iife',
|
|
23
39
|
compact: true,
|
|
24
40
|
exports: 'named',
|
|
25
41
|
globals,
|