@unbound-app/cli 0.1.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.d.mts +1 -0
- package/dist/index.mjs +984 -0
- package/package.json +37 -0
- package/templates/plugin/index.tsx +21 -0
- package/templates/plugin/manifest.json +13 -0
- package/templates/plugin/package.json +20 -0
- package/templates/plugin/rollup.config.mjs +27 -0
- package/templates/repository/.github/workflows/build.yml +15 -0
- package/templates/repository/README.md +28 -0
- package/templates/repository/gitignore +4 -0
- package/templates/repository/package.json +17 -0
- package/templates/repository/tsconfig.json +15 -0
- package/templates/repository/unbound.config.json +6 -0
- package/templates/theme/manifest.json +13 -0
- package/templates/theme/{{main}}.json +10 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cli, command } from "cleye";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
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
|
+
import { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import { glob } from "node:fs/promises";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
//#region src/lib/stdin.ts
|
|
15
|
+
/**
|
|
16
|
+
* @description Whether stdin is piped rather than an interactive terminal, meaning a command may read
|
|
17
|
+
* its input from it.
|
|
18
|
+
* @returns `true` when stdin is not a TTY.
|
|
19
|
+
*/
|
|
20
|
+
function isStdinPiped() {
|
|
21
|
+
return !process.stdin.isTTY;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @description Reads all of stdin to a trimmed string, for commands fed their input through a pipe.
|
|
25
|
+
* @returns The piped input.
|
|
26
|
+
*/
|
|
27
|
+
async function readStdin() {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
30
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/lib/config.ts
|
|
34
|
+
/** The filename walked up the directory tree to locate a workspace root. */
|
|
35
|
+
const CONFIG_FILENAME = "unbound.config.json";
|
|
36
|
+
const schema = z.object({
|
|
37
|
+
$schema: z.string().optional(),
|
|
38
|
+
addons: z.array(z.string()).default(["plugins/*", "themes/*"]),
|
|
39
|
+
build: z.string().default("bun run build"),
|
|
40
|
+
output: z.string().default("dist")
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* @description Walks up from `cwd` looking for an `unbound.config.json`, parsing the first one found
|
|
44
|
+
* against the config schema. When none exists the `cwd` is treated as a single-addon root carrying
|
|
45
|
+
* the schema defaults, so bare addons need no config.
|
|
46
|
+
* @param cwd The directory to start the upward search from.
|
|
47
|
+
* @returns The parsed config and the directory it resolves relative to.
|
|
48
|
+
*/
|
|
49
|
+
function loadConfig(cwd = process.cwd()) {
|
|
50
|
+
const found = findConfigFile(cwd);
|
|
51
|
+
if (!found) return {
|
|
52
|
+
config: schema.parse({}),
|
|
53
|
+
dir: resolve(cwd),
|
|
54
|
+
bare: true
|
|
55
|
+
};
|
|
56
|
+
const raw = JSON.parse(readFileSync(found, "utf8"));
|
|
57
|
+
return {
|
|
58
|
+
config: schema.parse(raw),
|
|
59
|
+
dir: dirname(found),
|
|
60
|
+
bare: false
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function findConfigFile(from) {
|
|
64
|
+
let dir = resolve(from);
|
|
65
|
+
while (true) {
|
|
66
|
+
const candidate = resolve(dir, CONFIG_FILENAME);
|
|
67
|
+
if (existsSync(candidate)) return candidate;
|
|
68
|
+
const parent = dirname(dir);
|
|
69
|
+
if (parent === dir) return void 0;
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/lib/context.ts
|
|
75
|
+
/** How long a one-shot command waits for the bridge to report a device before giving up. */
|
|
76
|
+
const DEVICE_WAIT_TIMEOUT_MS = 2e3;
|
|
77
|
+
/** How often the device-wait polls the controller's connection state. */
|
|
78
|
+
const DEVICE_WAIT_POLL_MS = 50;
|
|
79
|
+
/**
|
|
80
|
+
* @description Builds a {@link LazyContext} whose controller dials the bridge on the given port on
|
|
81
|
+
* first access to `client`, attaching as a controller through the shared endpoint marker rather than
|
|
82
|
+
* as a device. Commands that never read `client` never connect.
|
|
83
|
+
* @param port The port the bridge listens on.
|
|
84
|
+
* @returns A context that connects on demand.
|
|
85
|
+
*/
|
|
86
|
+
function createContext(port) {
|
|
87
|
+
const url = `ws://127.0.0.1:${port}?${CONTROLLER_ENDPOINT_MARKER}`;
|
|
88
|
+
let client;
|
|
89
|
+
return {
|
|
90
|
+
get client() {
|
|
91
|
+
if (client) return client;
|
|
92
|
+
client = new ControllerClient(url);
|
|
93
|
+
client.connect();
|
|
94
|
+
return client;
|
|
95
|
+
},
|
|
96
|
+
get connected() {
|
|
97
|
+
return client !== void 0;
|
|
98
|
+
},
|
|
99
|
+
close() {
|
|
100
|
+
client?.close();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* @description Waits for the bridge to report a connected device, for one-shot commands that dial in,
|
|
106
|
+
* run, and exit. Resolves early once a device is seen; resolves `false` once the timeout elapses so
|
|
107
|
+
* the caller can surface a connection error rather than hang.
|
|
108
|
+
* @param client The controller dialing the bridge.
|
|
109
|
+
* @param timeoutMs How long to wait before giving up.
|
|
110
|
+
* @returns Whether a device connected within the timeout.
|
|
111
|
+
*/
|
|
112
|
+
function waitForDevice(client, timeoutMs = DEVICE_WAIT_TIMEOUT_MS) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
if (client.isDeviceConnected) return resolve(true);
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
const timer = setInterval(() => {
|
|
117
|
+
if (client.isDeviceConnected) {
|
|
118
|
+
clearInterval(timer);
|
|
119
|
+
resolve(true);
|
|
120
|
+
} else if (Date.now() >= deadline) {
|
|
121
|
+
clearInterval(timer);
|
|
122
|
+
resolve(false);
|
|
123
|
+
}
|
|
124
|
+
}, DEVICE_WAIT_POLL_MS);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/lib/zod-flags.ts
|
|
129
|
+
/** The wrappers whose `innerType` carries the underlying type a flag should be built from. */
|
|
130
|
+
const WRAPPER_TYPES = /* @__PURE__ */ new Set([
|
|
131
|
+
"default",
|
|
132
|
+
"optional",
|
|
133
|
+
"nullable"
|
|
134
|
+
]);
|
|
135
|
+
const FLAG_CONSTRUCTORS = {
|
|
136
|
+
boolean: Boolean,
|
|
137
|
+
number: Number,
|
|
138
|
+
string: String
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* @description Derives a cleye flag descriptor from a zod field, reading its type through zod's own
|
|
142
|
+
* `def` introspection so the flag stays in sync with the schema rather than a hand-kept parallel map.
|
|
143
|
+
* @param field The zod field for a single schema property.
|
|
144
|
+
* @returns The cleye flag descriptor for that field.
|
|
145
|
+
*/
|
|
146
|
+
function fieldToFlag(field) {
|
|
147
|
+
let def = field.def;
|
|
148
|
+
let defaultValue;
|
|
149
|
+
while (WRAPPER_TYPES.has(def.type) && def.innerType) {
|
|
150
|
+
if (def.type === "default") defaultValue = def.defaultValue;
|
|
151
|
+
def = def.innerType.def;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
type: FLAG_CONSTRUCTORS[def.type] ?? String,
|
|
155
|
+
description: field.description,
|
|
156
|
+
default: defaultValue
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/adapters/cleye.ts
|
|
161
|
+
/** The value that marks a positional as fed from stdin instead of the command line. */
|
|
162
|
+
const STDIN_MARKER = "-";
|
|
163
|
+
/**
|
|
164
|
+
* @description Builds a cleye command from a registry definition: the fields named in
|
|
165
|
+
* `cli.positionals` become positional parameters, every other schema field becomes a flag whose type
|
|
166
|
+
* is derived from the zod field. The callback reads a stdin-fed positional when piped, validates the
|
|
167
|
+
* assembled input through the definition's schema, runs the handler, and exits `0`/`1`/`2` on
|
|
168
|
+
* success, on-device failure, and connection or usage error respectively.
|
|
169
|
+
* @param definition The command to adapt.
|
|
170
|
+
* @returns A cleye `command()` ready to register on the CLI.
|
|
171
|
+
*/
|
|
172
|
+
function toCleyeCommand(definition) {
|
|
173
|
+
const positionalNames = (definition.cli?.positionals ?? []).map(String);
|
|
174
|
+
const flags = { json: {
|
|
175
|
+
type: Boolean,
|
|
176
|
+
description: "Print the raw result as JSON instead of the formatted transcript.",
|
|
177
|
+
default: false
|
|
178
|
+
} };
|
|
179
|
+
for (const [name, field] of Object.entries(definition.schema.shape)) {
|
|
180
|
+
if (positionalNames.includes(name)) continue;
|
|
181
|
+
flags[name] = fieldToFlag(field);
|
|
182
|
+
}
|
|
183
|
+
return command({
|
|
184
|
+
name: definition.name,
|
|
185
|
+
description: definition.description,
|
|
186
|
+
parameters: positionalNames.map((name) => `[${name}]`),
|
|
187
|
+
flags
|
|
188
|
+
}, async (parsed) => {
|
|
189
|
+
const { json, ...flagInput } = parsed.flags;
|
|
190
|
+
const input = { ...flagInput };
|
|
191
|
+
for (const name of positionalNames) input[name] = parsed._[name];
|
|
192
|
+
await run(definition, input, positionalNames[0], Boolean(json));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function run(definition, input, stdinField, json) {
|
|
196
|
+
if (stdinField && (input[stdinField] === void 0 || input[stdinField] === STDIN_MARKER)) {
|
|
197
|
+
if (!isStdinPiped()) {
|
|
198
|
+
process.stderr.write(`Missing required argument: ${stdinField}.\n`);
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
input[stdinField] = await readStdin();
|
|
202
|
+
}
|
|
203
|
+
const parsed = definition.schema.safeParse(input);
|
|
204
|
+
if (!parsed.success) {
|
|
205
|
+
process.stderr.write(`${z.prettifyError(parsed.error)}\n`);
|
|
206
|
+
process.exit(2);
|
|
207
|
+
}
|
|
208
|
+
const port = parsed.data.port;
|
|
209
|
+
const context = createContext(port ?? NaN);
|
|
210
|
+
try {
|
|
211
|
+
const output = await definition.handler(parsed.data, context);
|
|
212
|
+
if (json) process.stdout.write(`${JSON.stringify(output.data ?? output)}\n`);
|
|
213
|
+
else if (output.ok) process.stdout.write(`${output.text}\n`);
|
|
214
|
+
else process.stderr.write(`${output.text}\n`);
|
|
215
|
+
if (output.ok) process.exit(0);
|
|
216
|
+
const connectionError = context.connected && !context.client.isDeviceConnected;
|
|
217
|
+
process.exit(connectionError ? 2 : 1);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof z.ZodError) {
|
|
220
|
+
process.stderr.write(`Invalid ${CONFIG_FILENAME}:\n${z.prettifyError(error)}\n`);
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
} finally {
|
|
225
|
+
context.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/adapters/mcp.ts
|
|
230
|
+
/**
|
|
231
|
+
* @description Registers a registry definition as an MCP tool on the given server: the definition's
|
|
232
|
+
* schema is the tool's input schema, and its handler output maps to a tool result — the formatted
|
|
233
|
+
* text as the sole content block, `ok: false` surfacing as `isError`.
|
|
234
|
+
* @param server The MCP server to register the tool on.
|
|
235
|
+
* @param definition The command to expose.
|
|
236
|
+
* @param context The runtime the handler evaluates against.
|
|
237
|
+
*/
|
|
238
|
+
function toMcpTool(server, definition, context) {
|
|
239
|
+
server.registerTool(definition.name, {
|
|
240
|
+
description: definition.description,
|
|
241
|
+
inputSchema: definition.schema
|
|
242
|
+
}, async (input) => {
|
|
243
|
+
const output = await definition.handler(input, context);
|
|
244
|
+
return {
|
|
245
|
+
isError: !output.ok,
|
|
246
|
+
content: [{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: output.text
|
|
249
|
+
}]
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/lib/templates.ts
|
|
255
|
+
/** The base of every documentation link surfaced by `ubd create`. */
|
|
256
|
+
const DOCS_BASE_URL = "https://docs.unbound.rip";
|
|
257
|
+
/**
|
|
258
|
+
* @description Resolves the bundled templates directory, walking up from this module so it works both
|
|
259
|
+
* from `src/` in development and from the built `dist/` on a published install, where `templates/` is
|
|
260
|
+
* shipped alongside via the package `files` field.
|
|
261
|
+
* @returns The absolute path to the `templates` directory.
|
|
262
|
+
*/
|
|
263
|
+
function templatesDir() {
|
|
264
|
+
let dir = import.meta.dirname;
|
|
265
|
+
while (true) {
|
|
266
|
+
const candidate = join(dir, "templates");
|
|
267
|
+
if (existsSync(candidate)) return candidate;
|
|
268
|
+
const parent = dirname(dir);
|
|
269
|
+
if (parent === dir) throw new Error("Could not locate the bundled templates directory.");
|
|
270
|
+
dir = parent;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* @description Resolves the absolute path to a single template directory.
|
|
275
|
+
* @param template The template to resolve.
|
|
276
|
+
* @returns The absolute path to that template.
|
|
277
|
+
*/
|
|
278
|
+
function templatePath(template) {
|
|
279
|
+
return resolve(templatesDir(), template);
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/lib/create-prompts.ts
|
|
283
|
+
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
284
|
+
/**
|
|
285
|
+
* @description Runs the interactive `ubd create` flow, prompting only for values the flags left
|
|
286
|
+
* unset so a fully-flagged invocation completes without a single prompt. Exits the process on
|
|
287
|
+
* cancellation.
|
|
288
|
+
* @param input The values supplied via flags.
|
|
289
|
+
* @param workspace The config found in or above the cwd, or `undefined` when none exists.
|
|
290
|
+
* @returns The fully-resolved options to scaffold from.
|
|
291
|
+
*/
|
|
292
|
+
async function resolveOptions(input, workspace) {
|
|
293
|
+
intro("ubd create");
|
|
294
|
+
const shape = await resolveShape(input.shape, Boolean(workspace));
|
|
295
|
+
const kind = await resolveKind(input.kind);
|
|
296
|
+
const name = await resolveName(input.name);
|
|
297
|
+
const displayName = await resolveText(input.displayName, "Display name", titleCase(name));
|
|
298
|
+
const description = await resolveText(input.description, "Description", "An Unbound addon.");
|
|
299
|
+
const authorName = await resolveText(input.authorName, "Author name", "you");
|
|
300
|
+
const authorId = await resolveOptionalText(input.authorId, "Author Discord id (optional)", "0");
|
|
301
|
+
const id = await resolveText(input.id, "Addon id", `${authorName}.${name}`);
|
|
302
|
+
const dir = shape === "add" ? void 0 : await resolveDir(input.dir, name);
|
|
303
|
+
const repository = shape === "repository";
|
|
304
|
+
return {
|
|
305
|
+
shape,
|
|
306
|
+
kind,
|
|
307
|
+
name,
|
|
308
|
+
displayName,
|
|
309
|
+
description,
|
|
310
|
+
authorName,
|
|
311
|
+
authorId,
|
|
312
|
+
id,
|
|
313
|
+
dir,
|
|
314
|
+
gitInit: repository ? await resolveConfirm(input.gitInit, "Initialize a git repository?") : false,
|
|
315
|
+
install: repository ? await resolveConfirm(input.install, "Install dependencies with bun?") : false
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async function resolveShape(flag, hasWorkspace) {
|
|
319
|
+
if (flag === "repository") return "repository";
|
|
320
|
+
if (flag === "addon") return hasWorkspace ? "add" : "addon";
|
|
321
|
+
if (hasWorkspace) return unwrap(await select({
|
|
322
|
+
message: "This directory is inside an Unbound workspace. What would you like to do?",
|
|
323
|
+
options: [{
|
|
324
|
+
value: "add",
|
|
325
|
+
label: "Add an addon to this workspace"
|
|
326
|
+
}, {
|
|
327
|
+
value: "repository",
|
|
328
|
+
label: "New repository"
|
|
329
|
+
}]
|
|
330
|
+
}));
|
|
331
|
+
return unwrap(await select({
|
|
332
|
+
message: "What would you like to create?",
|
|
333
|
+
options: [{
|
|
334
|
+
value: "repository",
|
|
335
|
+
label: "Repository",
|
|
336
|
+
hint: "recommended"
|
|
337
|
+
}, {
|
|
338
|
+
value: "addon",
|
|
339
|
+
label: "Single addon directory"
|
|
340
|
+
}]
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
async function resolveKind(flag) {
|
|
344
|
+
if (flag) return flag;
|
|
345
|
+
return unwrap(await select({
|
|
346
|
+
message: "Which kind of addon?",
|
|
347
|
+
options: [{
|
|
348
|
+
value: "plugin",
|
|
349
|
+
label: "Plugin",
|
|
350
|
+
hint: `patch the client. ${DOCS_BASE_URL}/plugins/introduction`
|
|
351
|
+
}, {
|
|
352
|
+
value: "theme",
|
|
353
|
+
label: "Theme",
|
|
354
|
+
hint: `recolor the UI. ${DOCS_BASE_URL}/themes/introduction`
|
|
355
|
+
}]
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
async function resolveName(flag) {
|
|
359
|
+
if (flag !== void 0) {
|
|
360
|
+
if (!KEBAB_CASE.test(flag)) {
|
|
361
|
+
cancel(`Invalid name "${flag}": must be kebab-case (lowercase, digits, hyphens).`);
|
|
362
|
+
process.exit(2);
|
|
363
|
+
}
|
|
364
|
+
return flag;
|
|
365
|
+
}
|
|
366
|
+
return unwrap(await text({
|
|
367
|
+
message: "Addon name",
|
|
368
|
+
placeholder: "my-addon",
|
|
369
|
+
validate(input) {
|
|
370
|
+
if (!input) return "A name is required.";
|
|
371
|
+
if (!KEBAB_CASE.test(input)) return "Must be kebab-case (lowercase, digits, hyphens).";
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
async function resolveText(flag, message, initialValue) {
|
|
376
|
+
if (flag !== void 0) return flag;
|
|
377
|
+
return unwrap(await text({
|
|
378
|
+
message,
|
|
379
|
+
initialValue
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
async function resolveOptionalText(flag, message, fallback) {
|
|
383
|
+
if (flag !== void 0) return flag;
|
|
384
|
+
const resolved = unwrap(await text({
|
|
385
|
+
message,
|
|
386
|
+
placeholder: fallback
|
|
387
|
+
}));
|
|
388
|
+
return resolved === "" ? fallback : resolved;
|
|
389
|
+
}
|
|
390
|
+
async function resolveDir(flag, name) {
|
|
391
|
+
if (flag !== void 0) return flag;
|
|
392
|
+
return unwrap(await text({
|
|
393
|
+
message: "Target directory",
|
|
394
|
+
initialValue: `./${name}`
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
async function resolveConfirm(flag, message) {
|
|
398
|
+
if (flag !== void 0) return flag;
|
|
399
|
+
return unwrap(await confirm({
|
|
400
|
+
message,
|
|
401
|
+
initialValue: true
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
function unwrap(value) {
|
|
405
|
+
if (isCancel(value)) {
|
|
406
|
+
cancel("Cancelled.");
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
return value;
|
|
410
|
+
}
|
|
411
|
+
function titleCase(name) {
|
|
412
|
+
return name.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/lib/scaffold.ts
|
|
416
|
+
/** npm and bun drop dotfiles named `.gitignore` from tarballs, so templates store it undotted. */
|
|
417
|
+
const GITIGNORE_SOURCE = "gitignore";
|
|
418
|
+
/**
|
|
419
|
+
* @description Applies `{{token}}` substitution to a string, replacing every known token with its
|
|
420
|
+
* value and leaving unknown `{{…}}` sequences untouched.
|
|
421
|
+
* @param input The string to substitute over (file contents or a path segment).
|
|
422
|
+
* @param tokens The token values to inject.
|
|
423
|
+
* @returns The substituted string.
|
|
424
|
+
*/
|
|
425
|
+
function substitute(input, tokens) {
|
|
426
|
+
return input.replace(/\{\{(\w+)\}\}/g, (match, token) => {
|
|
427
|
+
return token in tokens ? tokens[token] : match;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* @description Recursively copies a template directory into a destination, substituting tokens across
|
|
432
|
+
* both file contents and file/directory names and renaming the undotted `gitignore` to `.gitignore`
|
|
433
|
+
* so it survives publishing yet lands as a real dotfile.
|
|
434
|
+
* @param from The template directory to copy.
|
|
435
|
+
* @param to The destination directory (created if absent).
|
|
436
|
+
* @param tokens The token values to inject.
|
|
437
|
+
*/
|
|
438
|
+
function scaffoldTemplate(from, to, tokens) {
|
|
439
|
+
mkdirSync(to, { recursive: true });
|
|
440
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
441
|
+
const source = join(from, entry.name);
|
|
442
|
+
const target = join(to, entry.name === GITIGNORE_SOURCE ? ".gitignore" : substitute(entry.name, tokens));
|
|
443
|
+
if (entry.isDirectory()) {
|
|
444
|
+
scaffoldTemplate(source, target, tokens);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
writeFileSync(target, substitute(readFileSync(source, "utf8"), tokens));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/lib/create.ts
|
|
452
|
+
/**
|
|
453
|
+
* @description Scaffolds the chosen shape into a validated target directory: a repository stamps the
|
|
454
|
+
* workspace template then the starter addon under `plugins/`/`themes/`; a single addon or an addon
|
|
455
|
+
* added to an existing workspace stamps only the kind's template. Runs `git init` and `bun install`
|
|
456
|
+
* for repositories when confirmed.
|
|
457
|
+
* @param options The fully-resolved create options.
|
|
458
|
+
* @param workspace The config found in or above the cwd, or `undefined` when none exists.
|
|
459
|
+
* @returns Where the addon landed, for the next-step outro.
|
|
460
|
+
*/
|
|
461
|
+
function scaffold(options, workspace) {
|
|
462
|
+
const tokens = toTokens(options);
|
|
463
|
+
if (options.shape === "repository") return scaffoldRepository(options, tokens);
|
|
464
|
+
if (options.shape === "add") return scaffoldWorkspaceAddon(options, tokens, workspace);
|
|
465
|
+
return scaffoldBareAddon(options, tokens);
|
|
466
|
+
}
|
|
467
|
+
function scaffoldRepository(options, tokens) {
|
|
468
|
+
const dir = resolve(options.dir ?? `./${options.name}`);
|
|
469
|
+
assertEmpty(dir);
|
|
470
|
+
scaffoldTemplate(templatePath("repository"), dir, tokens);
|
|
471
|
+
const folder = options.kind === "plugin" ? "plugins" : "themes";
|
|
472
|
+
scaffoldTemplate(templatePath(options.kind), join(dir, folder, options.name), tokens);
|
|
473
|
+
if (options.gitInit) gitInit(dir);
|
|
474
|
+
if (options.install) bunInstall(dir);
|
|
475
|
+
return {
|
|
476
|
+
dir,
|
|
477
|
+
kind: options.kind,
|
|
478
|
+
name: options.name
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function scaffoldBareAddon(options, tokens) {
|
|
482
|
+
const dir = resolve(options.dir ?? `./${options.name}`);
|
|
483
|
+
assertEmpty(dir);
|
|
484
|
+
scaffoldTemplate(templatePath(options.kind), dir, tokens);
|
|
485
|
+
return {
|
|
486
|
+
dir,
|
|
487
|
+
kind: options.kind,
|
|
488
|
+
name: options.name
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function scaffoldWorkspaceAddon(options, tokens, workspace) {
|
|
492
|
+
if (!workspace) throw new Error("Cannot add an addon: no workspace config was found.");
|
|
493
|
+
const dir = join(workspaceAddonDir(workspace, options.kind), options.name);
|
|
494
|
+
assertEmpty(dir);
|
|
495
|
+
scaffoldTemplate(templatePath(options.kind), dir, tokens);
|
|
496
|
+
return {
|
|
497
|
+
dir,
|
|
498
|
+
kind: options.kind,
|
|
499
|
+
name: options.name
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* @description Picks the workspace directory a new addon of the given kind belongs in, matching the
|
|
504
|
+
* config `addons` glob whose leading segment names the kind and stripping its trailing `*` segment.
|
|
505
|
+
* Falls back to `plugins`/`themes` next to the config when no glob matches.
|
|
506
|
+
* @param workspace The resolved workspace config.
|
|
507
|
+
* @param kind The addon kind being added.
|
|
508
|
+
* @returns The absolute directory the addon folder should be created under.
|
|
509
|
+
*/
|
|
510
|
+
function workspaceAddonDir(workspace, kind) {
|
|
511
|
+
const keyword = kind === "plugin" ? "plugin" : "theme";
|
|
512
|
+
const glob = workspace.config.addons.find((pattern) => pattern.includes(keyword));
|
|
513
|
+
const base = glob ? glob.replace(/\/?\*+.*$/, "") : `${keyword}s`;
|
|
514
|
+
return resolve(workspace.dir, base);
|
|
515
|
+
}
|
|
516
|
+
function assertEmpty(dir) {
|
|
517
|
+
if (!existsSync(dir)) return;
|
|
518
|
+
if (readdirSync(dir).length > 0) throw new Error(`Target directory is not empty: ${dir}`);
|
|
519
|
+
}
|
|
520
|
+
function gitInit(dir) {
|
|
521
|
+
spawnSync("git", ["init"], {
|
|
522
|
+
cwd: dir,
|
|
523
|
+
stdio: "inherit"
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function bunInstall(dir) {
|
|
527
|
+
spawnSync("bun", ["install"], {
|
|
528
|
+
cwd: dir,
|
|
529
|
+
stdio: "inherit"
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function toTokens(options) {
|
|
533
|
+
return {
|
|
534
|
+
name: options.name,
|
|
535
|
+
id: options.id,
|
|
536
|
+
displayName: options.displayName,
|
|
537
|
+
description: options.description,
|
|
538
|
+
authorName: options.authorName,
|
|
539
|
+
authorId: options.authorId,
|
|
540
|
+
main: options.kind === "theme" ? "theme" : "index.tsx"
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region src/commands/create.ts
|
|
545
|
+
const createCommand = {
|
|
546
|
+
name: "create",
|
|
547
|
+
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.",
|
|
548
|
+
surfaces: {
|
|
549
|
+
cli: true,
|
|
550
|
+
mcp: false
|
|
551
|
+
},
|
|
552
|
+
schema: z.object({
|
|
553
|
+
kind: z.enum(["plugin", "theme"]).optional().describe("The addon kind to scaffold."),
|
|
554
|
+
name: z.string().optional().describe("The addon name (kebab-case)."),
|
|
555
|
+
dir: z.string().optional().describe("The target directory (default ./<name>)."),
|
|
556
|
+
displayName: z.string().optional().describe("The human-readable addon name."),
|
|
557
|
+
description: z.string().optional().describe("A one-line description."),
|
|
558
|
+
authorName: z.string().optional().describe("The author name."),
|
|
559
|
+
authorId: z.string().optional().describe("The author Discord id."),
|
|
560
|
+
id: z.string().optional().describe("The manifest id (default <authorName>.<name>)."),
|
|
561
|
+
shape: z.enum(["repository", "addon"]).optional().describe("Scaffold a repository or a single addon."),
|
|
562
|
+
gitInit: z.boolean().optional().describe("Initialize a git repository (repository shape only)."),
|
|
563
|
+
install: z.boolean().optional().describe("Install dependencies with bun (repository shape only).")
|
|
564
|
+
}),
|
|
565
|
+
async handler(input) {
|
|
566
|
+
const config = loadConfig();
|
|
567
|
+
const workspace = config.bare ? void 0 : config;
|
|
568
|
+
const options = await resolveOptions(input, workspace);
|
|
569
|
+
try {
|
|
570
|
+
printOutro(scaffold(options, workspace));
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
text: ""
|
|
574
|
+
};
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
text: error instanceof Error ? error.message : String(error)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
function printOutro(result) {
|
|
584
|
+
const kindDocs = `${DOCS_BASE_URL}/${result.kind}s/introduction`;
|
|
585
|
+
const manifestDocs = `${DOCS_BASE_URL}/addons/manifest`;
|
|
586
|
+
note([
|
|
587
|
+
`cd ${relative(process.cwd(), result.dir) || "."}`,
|
|
588
|
+
"ubd dev",
|
|
589
|
+
`Learn the ${result.kind} API: ${kindDocs}`,
|
|
590
|
+
`Manifest reference: ${manifestDocs}`
|
|
591
|
+
].join("\n"), "Next steps");
|
|
592
|
+
outro(`${result.name} ready.`);
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/lib/discovery.ts
|
|
596
|
+
/**
|
|
597
|
+
* @description Discovers every addon reachable through the config's `addons` globs. A directory is an
|
|
598
|
+
* addon iff it contains a `manifest.json`; its kind is derived from the manifest `main` (a `.json`
|
|
599
|
+
* entry point is a theme, anything else a plugin) and it is static when its directory carries no
|
|
600
|
+
* `package.json` with a `build` script.
|
|
601
|
+
* @param resolved The loaded config paired with the directory its globs resolve relative to.
|
|
602
|
+
* @returns Every discovered addon, keyed implicitly by directory.
|
|
603
|
+
*/
|
|
604
|
+
async function discoverAddons(resolved) {
|
|
605
|
+
const { config, dir, bare } = resolved;
|
|
606
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
607
|
+
if (bare) dirs.add(dir);
|
|
608
|
+
else for (const pattern of config.addons) for await (const match of glob(pattern, { cwd: dir })) dirs.add(resolve(dir, match));
|
|
609
|
+
const addons = [];
|
|
610
|
+
for (const addonDir of dirs) {
|
|
611
|
+
const addon = readAddon(addonDir, config.output);
|
|
612
|
+
if (addon) addons.push(addon);
|
|
613
|
+
}
|
|
614
|
+
return addons;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* @description Reads a single directory as an addon, returning `undefined` when it has no
|
|
618
|
+
* `manifest.json` or the manifest lacks the fields discovery needs.
|
|
619
|
+
* @param addonDir The absolute directory to inspect.
|
|
620
|
+
* @param output The config's output directory, relative to the addon.
|
|
621
|
+
* @returns The discovered addon, or `undefined` if the directory is not an addon.
|
|
622
|
+
*/
|
|
623
|
+
function readAddon(addonDir, output) {
|
|
624
|
+
const manifestPath = join(addonDir, "manifest.json");
|
|
625
|
+
if (!existsSync(manifestPath)) return void 0;
|
|
626
|
+
let manifest;
|
|
627
|
+
try {
|
|
628
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
process.stderr.write(`Skipping ${addonDir}: its manifest.json could not be read: ${error}\n`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (typeof manifest.id !== "string" || typeof manifest.main !== "string") return void 0;
|
|
634
|
+
const kind = manifest.main.endsWith(".json") ? "theme" : "plugin";
|
|
635
|
+
const isStatic = !hasBuildScript(addonDir);
|
|
636
|
+
return {
|
|
637
|
+
id: manifest.id,
|
|
638
|
+
kind,
|
|
639
|
+
dir: addonDir,
|
|
640
|
+
output: isStatic ? addonDir : join(addonDir, output),
|
|
641
|
+
static: isStatic
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function hasBuildScript(addonDir) {
|
|
645
|
+
const packagePath = join(addonDir, "package.json");
|
|
646
|
+
if (!existsSync(packagePath)) return false;
|
|
647
|
+
try {
|
|
648
|
+
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
649
|
+
return Boolean(pkg.scripts?.build);
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/lib/build-addon.ts
|
|
656
|
+
/**
|
|
657
|
+
* @description Builds a single addon by running the config's `build` command with cwd set to the
|
|
658
|
+
* addon directory. Static addons carry no build step, so they resolve immediately as successful.
|
|
659
|
+
* stdout and stderr are inherited so the build's own output surfaces live.
|
|
660
|
+
* @param addon The addon to build.
|
|
661
|
+
* @param buildCommand The shell command to run in the addon directory.
|
|
662
|
+
* @returns Whether the build succeeded (always `true` for static addons).
|
|
663
|
+
*/
|
|
664
|
+
function buildAddon(addon, buildCommand) {
|
|
665
|
+
if (addon.static) return Promise.resolve(true);
|
|
666
|
+
return new Promise((resolve) => {
|
|
667
|
+
const child = spawn(buildCommand, {
|
|
668
|
+
cwd: addon.dir,
|
|
669
|
+
shell: true,
|
|
670
|
+
stdio: "inherit"
|
|
671
|
+
});
|
|
672
|
+
child.on("error", () => resolve(false));
|
|
673
|
+
child.on("close", (code) => resolve(code === 0));
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/commands/build.ts
|
|
678
|
+
const buildCommand = {
|
|
679
|
+
name: "build",
|
|
680
|
+
description: "Build every addon in the workspace once, reporting per-addon success and failure.",
|
|
681
|
+
surfaces: {
|
|
682
|
+
cli: true,
|
|
683
|
+
mcp: false
|
|
684
|
+
},
|
|
685
|
+
schema: z.object({}),
|
|
686
|
+
async handler() {
|
|
687
|
+
const resolved = loadConfig();
|
|
688
|
+
const addons = await discoverAddons(resolved);
|
|
689
|
+
if (addons.length === 0) return {
|
|
690
|
+
ok: true,
|
|
691
|
+
text: "No addons found."
|
|
692
|
+
};
|
|
693
|
+
const failed = [];
|
|
694
|
+
for (const addon of addons) {
|
|
695
|
+
if (addon.static) {
|
|
696
|
+
process.stdout.write(`Skipping ${addon.id} (static addon).\n`);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
process.stdout.write(`Building ${addon.id}…\n`);
|
|
700
|
+
if (await buildAddon(addon, resolved.config.build)) process.stdout.write(`Built ${addon.id}.\n`);
|
|
701
|
+
else {
|
|
702
|
+
failed.push(addon.id);
|
|
703
|
+
process.stderr.write(`Failed to build ${addon.id}.\n`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (failed.length > 0) return {
|
|
707
|
+
ok: false,
|
|
708
|
+
text: `Failed to build: ${failed.join(", ")}.`
|
|
709
|
+
};
|
|
710
|
+
return {
|
|
711
|
+
ok: true,
|
|
712
|
+
text: "All addons built."
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/lib/format.ts
|
|
718
|
+
/**
|
|
719
|
+
* @description Renders an {@link EvalResult} into the human-readable transcript both surfaces print:
|
|
720
|
+
* captured console output first, then the value or error, each under its own header.
|
|
721
|
+
* @param result The device's eval result.
|
|
722
|
+
* @returns The formatted transcript.
|
|
723
|
+
*/
|
|
724
|
+
function formatResult(result) {
|
|
725
|
+
const parts = [];
|
|
726
|
+
if (result.logs?.length) parts.push("--- console ---", result.logs.map(formatLog).join("\n"));
|
|
727
|
+
if (result.ok) parts.push("--- result ---", result.value ?? "undefined");
|
|
728
|
+
else parts.push("--- error ---", result.error ?? "Unknown error.");
|
|
729
|
+
return parts.join("\n");
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* @description Renders a single {@link LogMessage} as a level-tagged line for the console transcript.
|
|
733
|
+
* @param log The captured log line.
|
|
734
|
+
* @returns The `[level] message` line.
|
|
735
|
+
*/
|
|
736
|
+
function formatLog(log) {
|
|
737
|
+
return `[${log.level === 3 ? "error" : log.level === 2 ? "warn" : "log"}] ${log.message}`;
|
|
738
|
+
}
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/commands/eval.ts
|
|
741
|
+
const evalCommand = {
|
|
742
|
+
name: "eval",
|
|
743
|
+
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.",
|
|
744
|
+
surfaces: {
|
|
745
|
+
cli: true,
|
|
746
|
+
mcp: true
|
|
747
|
+
},
|
|
748
|
+
schema: z.object({
|
|
749
|
+
code: z.string().describe("The JavaScript to evaluate on the device."),
|
|
750
|
+
port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.")
|
|
751
|
+
}),
|
|
752
|
+
cli: { positionals: ["code"] },
|
|
753
|
+
async handler({ code }, { client }) {
|
|
754
|
+
await waitForDevice(client);
|
|
755
|
+
if (!client.isDeviceConnected) return {
|
|
756
|
+
ok: false,
|
|
757
|
+
text: "No device is connected to the debugger. Launch the app so its debugger dials the bridge, then retry."
|
|
758
|
+
};
|
|
759
|
+
const result = await client.evaluate(code);
|
|
760
|
+
return {
|
|
761
|
+
ok: result.ok,
|
|
762
|
+
text: formatResult(result),
|
|
763
|
+
data: result
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/lib/reload-transport.ts
|
|
769
|
+
/**
|
|
770
|
+
* The shipped transport: hot reload is not yet wired, so it only reports that the app must be
|
|
771
|
+
* restarted for the rebuild to take effect. The real mechanism swaps this out without touching
|
|
772
|
+
* `ubd dev`.
|
|
773
|
+
*/
|
|
774
|
+
const stubReloadTransport = { async reload(addon) {
|
|
775
|
+
process.stdout.write(`Rebuilt ${addon.id}, but hot reload is not yet available — restart the app to load it.\n`);
|
|
776
|
+
} };
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/lib/dev-server.ts
|
|
779
|
+
/** How long file changes inside one addon settle before that addon rebuilds. */
|
|
780
|
+
const DEBOUNCE_MS = 150;
|
|
781
|
+
/**
|
|
782
|
+
* Watches every discovered addon and rebuilds only the addon whose files changed, debounced per
|
|
783
|
+
* addon. Directory creates and deletes under the workspace re-run discovery so addons added or
|
|
784
|
+
* removed while the server runs are picked up. After each successful build the reload transport is
|
|
785
|
+
* called; static addons skip the build step but still count as a successful build for reload.
|
|
786
|
+
*/
|
|
787
|
+
var DevServer = class {
|
|
788
|
+
resolved;
|
|
789
|
+
transport;
|
|
790
|
+
watcher = null;
|
|
791
|
+
addons = /* @__PURE__ */ new Map();
|
|
792
|
+
timers = /* @__PURE__ */ new Map();
|
|
793
|
+
constructor(resolved, transport) {
|
|
794
|
+
this.resolved = resolved;
|
|
795
|
+
this.transport = transport;
|
|
796
|
+
}
|
|
797
|
+
/** @description Runs initial discovery, builds every addon once, then watches until {@link stop}ped. */
|
|
798
|
+
async start() {
|
|
799
|
+
await this.rediscover();
|
|
800
|
+
for (const addon of this.addons.values()) await this.rebuild(addon);
|
|
801
|
+
this.watcher = watch(this.resolved.dir, { recursive: true }, (_event, filename) => {
|
|
802
|
+
if (!filename) return;
|
|
803
|
+
this.onChange(resolve(this.resolved.dir, filename));
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/** @description Tears the server down, clearing the watcher and any pending rebuilds. Twin of {@link start}. */
|
|
807
|
+
stop() {
|
|
808
|
+
this.watcher?.close();
|
|
809
|
+
this.watcher = null;
|
|
810
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
811
|
+
this.timers.clear();
|
|
812
|
+
}
|
|
813
|
+
onChange(changedPath) {
|
|
814
|
+
if (isIgnoredPath(changedPath)) return;
|
|
815
|
+
if (isManifestPath(changedPath)) {
|
|
816
|
+
this.rediscover();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const addon = this.findOwner(changedPath);
|
|
820
|
+
if (!addon) return;
|
|
821
|
+
if (!addon.static && isInside(changedPath, addon.output)) return;
|
|
822
|
+
this.scheduleRebuild(addon);
|
|
823
|
+
}
|
|
824
|
+
scheduleRebuild(addon) {
|
|
825
|
+
const existing = this.timers.get(addon.dir);
|
|
826
|
+
if (existing) clearTimeout(existing);
|
|
827
|
+
const timer = setTimeout(() => {
|
|
828
|
+
this.timers.delete(addon.dir);
|
|
829
|
+
this.rebuild(addon);
|
|
830
|
+
}, DEBOUNCE_MS);
|
|
831
|
+
this.timers.set(addon.dir, timer);
|
|
832
|
+
}
|
|
833
|
+
async rebuild(addon) {
|
|
834
|
+
if (!addon.static) process.stdout.write(`Building ${addon.id}…\n`);
|
|
835
|
+
if (!await buildAddon(addon, this.resolved.config.build)) {
|
|
836
|
+
process.stderr.write(`Failed to build ${addon.id}.\n`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!addon.static) process.stdout.write(`Built ${addon.id}.\n`);
|
|
840
|
+
await this.transport.reload(toBuiltAddon(addon));
|
|
841
|
+
}
|
|
842
|
+
async rediscover() {
|
|
843
|
+
const discovered = await discoverAddons(this.resolved);
|
|
844
|
+
const next = new Map(discovered.map((addon) => [addon.dir, addon]));
|
|
845
|
+
for (const dir of this.addons.keys()) if (!next.has(dir)) process.stdout.write(`Addon at ${dir} removed.\n`);
|
|
846
|
+
for (const [dir, addon] of next) if (!this.addons.has(dir)) process.stdout.write(`Discovered addon ${addon.id}.\n`);
|
|
847
|
+
this.addons = next;
|
|
848
|
+
}
|
|
849
|
+
findOwner(changedPath) {
|
|
850
|
+
for (const addon of this.addons.values()) if (isInside(changedPath, addon.dir)) return addon;
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
/** Directory names whose contents are never source and would otherwise churn the watcher. */
|
|
854
|
+
const IGNORED_SEGMENTS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
855
|
+
function isIgnoredPath(changedPath) {
|
|
856
|
+
return changedPath.split(sep).some((segment) => IGNORED_SEGMENTS.has(segment));
|
|
857
|
+
}
|
|
858
|
+
function isInside(changedPath, dir) {
|
|
859
|
+
return changedPath === dir || changedPath.startsWith(dir + sep);
|
|
860
|
+
}
|
|
861
|
+
function isManifestPath(changedPath) {
|
|
862
|
+
return changedPath.endsWith(join(sep, "manifest.json"));
|
|
863
|
+
}
|
|
864
|
+
function toBuiltAddon(addon) {
|
|
865
|
+
return {
|
|
866
|
+
id: addon.id,
|
|
867
|
+
kind: addon.kind,
|
|
868
|
+
dir: addon.dir,
|
|
869
|
+
output: addon.output
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/registry.ts
|
|
874
|
+
/**
|
|
875
|
+
* Every command defined once, from which both surfaces derive: the CLI adapter builds a cleye
|
|
876
|
+
* command per entry, the MCP adapter registers a tool for each entry opted into `surfaces.mcp`.
|
|
877
|
+
*/
|
|
878
|
+
const commands = [
|
|
879
|
+
evalCommand,
|
|
880
|
+
buildCommand,
|
|
881
|
+
{
|
|
882
|
+
name: "dev",
|
|
883
|
+
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.",
|
|
884
|
+
surfaces: {
|
|
885
|
+
cli: true,
|
|
886
|
+
mcp: false
|
|
887
|
+
},
|
|
888
|
+
schema: z.object({}),
|
|
889
|
+
async handler() {
|
|
890
|
+
const server = new DevServer(loadConfig(), stubReloadTransport);
|
|
891
|
+
process.on("SIGINT", () => {
|
|
892
|
+
server.stop();
|
|
893
|
+
process.exit(0);
|
|
894
|
+
});
|
|
895
|
+
await server.start();
|
|
896
|
+
return new Promise(() => {});
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
createCommand
|
|
900
|
+
];
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/commands/mcp.ts
|
|
903
|
+
/**
|
|
904
|
+
* @description The `ubd mcp` command: a stdio MCP server exposing every registry entry opted into
|
|
905
|
+
* `surfaces.mcp`, each riding a single long-lived controller connection to the bridge. This is the
|
|
906
|
+
* relocated debugger MCP, now registry-driven.
|
|
907
|
+
*/
|
|
908
|
+
const mcpCommand = command({
|
|
909
|
+
name: "mcp",
|
|
910
|
+
description: "Serve the debugger as a stdio MCP server for AI clients.",
|
|
911
|
+
flags: { port: {
|
|
912
|
+
type: Number,
|
|
913
|
+
description: "The port the debugger bridge listens on.",
|
|
914
|
+
default: DEFAULT_BRIDGE_PORT
|
|
915
|
+
} }
|
|
916
|
+
}, async (parsed) => {
|
|
917
|
+
const context = createContext(parsed.flags.port);
|
|
918
|
+
const server = new McpServer({
|
|
919
|
+
name: "ubd",
|
|
920
|
+
version: "1.0.0"
|
|
921
|
+
});
|
|
922
|
+
for (const definition of commands) {
|
|
923
|
+
if (!definition.surfaces.mcp) continue;
|
|
924
|
+
toMcpTool(server, definition, context);
|
|
925
|
+
}
|
|
926
|
+
const transport = new StdioServerTransport();
|
|
927
|
+
await server.connect(transport);
|
|
928
|
+
});
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/lib/menu.ts
|
|
931
|
+
/** Concise, menu-friendly summaries keyed by command name; the registry descriptions are tuned for
|
|
932
|
+
* the MCP surface and too long for a tidy list. Presentation copy belongs at the CLI edge. */
|
|
933
|
+
const SUMMARIES = {
|
|
934
|
+
create: "Scaffold a new addon or workspace.",
|
|
935
|
+
dev: "Watch addons and rebuild on change.",
|
|
936
|
+
build: "Build every addon in the workspace.",
|
|
937
|
+
eval: "Run JavaScript on the connected device.",
|
|
938
|
+
mcp: "Serve the debugger as an MCP server."
|
|
939
|
+
};
|
|
940
|
+
/** The order commands appear in the menu, most-reached-for first. */
|
|
941
|
+
const ORDER = [
|
|
942
|
+
"create",
|
|
943
|
+
"dev",
|
|
944
|
+
"build",
|
|
945
|
+
"eval",
|
|
946
|
+
"mcp"
|
|
947
|
+
];
|
|
948
|
+
/**
|
|
949
|
+
* @description Renders the branded `ubd` menu shown when the CLI runs with no command: a colored
|
|
950
|
+
* banner, every command with a one-line summary, and usage hints. Command names are sourced from the
|
|
951
|
+
* registry (plus the standalone `mcp` command) so a new command surfaces here once it is registered.
|
|
952
|
+
* @returns The menu as a single printable string.
|
|
953
|
+
*/
|
|
954
|
+
function renderMenu() {
|
|
955
|
+
const names = /* @__PURE__ */ new Set([...commands.map((command) => command.name), "mcp"]);
|
|
956
|
+
const ordered = [...ORDER.filter((name) => names.has(name)), ...[...names].filter((name) => !ORDER.includes(name))];
|
|
957
|
+
const width = Math.max(...ordered.map((name) => name.length));
|
|
958
|
+
const rows = ordered.map((name) => {
|
|
959
|
+
return ` ${pc.green(name.padEnd(width))} ${pc.dim(SUMMARIES[name] ?? "")}`;
|
|
960
|
+
});
|
|
961
|
+
return [
|
|
962
|
+
"",
|
|
963
|
+
` ${pc.bold(pc.magenta("ubd"))} ${pc.dim("· the Unbound addon toolkit")}`,
|
|
964
|
+
"",
|
|
965
|
+
` ${pc.bold("Usage")}`,
|
|
966
|
+
` ${pc.cyan("ubd")} ${pc.yellow("<command>")} ${pc.dim("[flags]")}`,
|
|
967
|
+
"",
|
|
968
|
+
` ${pc.bold("Commands")}`,
|
|
969
|
+
...rows,
|
|
970
|
+
"",
|
|
971
|
+
` ${pc.dim("Run")} ${pc.cyan("ubd <command> --help")} ${pc.dim("for details.")}`,
|
|
972
|
+
` ${pc.dim("New here? Start with")} ${pc.cyan("ubd create")}${pc.dim(".")}`,
|
|
973
|
+
""
|
|
974
|
+
].join("\n");
|
|
975
|
+
}
|
|
976
|
+
//#endregion
|
|
977
|
+
//#region src/index.ts
|
|
978
|
+
const argv = cli({
|
|
979
|
+
name: "ubd",
|
|
980
|
+
commands: [...commands.map(toCleyeCommand), mcpCommand]
|
|
981
|
+
});
|
|
982
|
+
if (!argv.command && argv._.length === 0 && !argv.flags.help && !argv.flags.version) process.stdout.write(`${renderMenu()}\n`);
|
|
983
|
+
//#endregion
|
|
984
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unbound-app/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The `ubd` command line for the Unbound client: evaluate on-device code and serve the debugger as an MCP.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Mario P.",
|
|
7
|
+
"email": "contact@marioparaschiv.com",
|
|
8
|
+
"url": "https://github.com/marioparaschiv"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"ubd": "./dist/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"templates"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"module": "./dist/index.mjs",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsdown"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clack/prompts": "^1.7.0",
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
+
"@unbound-app/debugger-protocol": "0.1.0",
|
|
26
|
+
"cleye": "^1.3.4",
|
|
27
|
+
"picocolors": "^1.1.1",
|
|
28
|
+
"zod": "^4.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"tsdown": "^0.22.9"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { metro, patcher } from '@unbound-app/api';
|
|
2
|
+
|
|
3
|
+
const unpatches: (() => void)[] = [];
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
start() {
|
|
7
|
+
const UserStore = metro.findByProps('getCurrentUser', 'getUser');
|
|
8
|
+
|
|
9
|
+
unpatches.push(
|
|
10
|
+
patcher.after(UserStore, 'getCurrentUser', ({ result }) => {
|
|
11
|
+
console.log('[{{displayName}}] getCurrentUser returned', result?.username);
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
stop() {
|
|
17
|
+
for (const unpatch of unpatches) unpatch();
|
|
18
|
+
|
|
19
|
+
unpatches.length = 0;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "{{description}}",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "rollup -c"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@unbound-app/api": "^1.0.0",
|
|
12
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
13
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
14
|
+
"@types/react": "^19.2.17",
|
|
15
|
+
"react-native": "^0.86.0",
|
|
16
|
+
"rollup": "^4.62.2",
|
|
17
|
+
"rollup-plugin-iife": "^0.8.0",
|
|
18
|
+
"rollup-plugin-swc3": "^0.12.1"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
|
2
|
+
import { swc } from 'rollup-plugin-swc3';
|
|
3
|
+
import json from '@rollup/plugin-json';
|
|
4
|
+
import iife from 'rollup-plugin-iife';
|
|
5
|
+
|
|
6
|
+
const globals = {
|
|
7
|
+
'@unbound-app/api': 'window.unbound',
|
|
8
|
+
'react': 'window.React',
|
|
9
|
+
'react-native': 'window.ReactNative',
|
|
10
|
+
'react-native-reanimated': 'window.unbound.metro.common.Reanimated',
|
|
11
|
+
'@react-native-clipboard/clipboard': 'window.unbound.metro.common.Clipboard',
|
|
12
|
+
'moment': 'window.unbound.metro.common.Moment',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** @type {import('rollup').RollupOptions} */
|
|
16
|
+
export default {
|
|
17
|
+
input: 'index.tsx',
|
|
18
|
+
external: Object.keys(globals),
|
|
19
|
+
plugins: [nodeResolve(), json(), swc({ tsconfig: false }), iife()],
|
|
20
|
+
output: {
|
|
21
|
+
dir: 'dist',
|
|
22
|
+
format: 'es',
|
|
23
|
+
compact: true,
|
|
24
|
+
exports: 'named',
|
|
25
|
+
globals,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# {{displayName}}
|
|
2
|
+
|
|
3
|
+
{{description}}
|
|
4
|
+
|
|
5
|
+
An [Unbound](https://unbound.rip) addon workspace scaffolded with `ubd create`.
|
|
6
|
+
|
|
7
|
+
## Development
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun install
|
|
11
|
+
bun run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`ubd dev` watches every addon under `plugins/` and `themes/`, rebuilding the one you change.
|
|
15
|
+
|
|
16
|
+
## Building
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Builds every addon once into its `dist/` directory.
|
|
23
|
+
|
|
24
|
+
## Learn more
|
|
25
|
+
|
|
26
|
+
- Plugins: https://docs.unbound.rip/plugins/introduction
|
|
27
|
+
- Themes: https://docs.unbound.rip/themes/introduction
|
|
28
|
+
- Manifest reference: https://docs.unbound.rip/addons/manifest
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"plugins/*",
|
|
8
|
+
"themes/*"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "ubd dev",
|
|
12
|
+
"build": "ubd build"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@unbound-app/cli": "^1.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"noEmit": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["plugins", "themes"]
|
|
15
|
+
}
|