@unbound-app/cli 0.4.0 → 0.7.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 +428 -97
- package/package.json +9 -4
package/dist/index.mjs
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cli, command } from "cleye";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { ControllerClient } from "@unbound-app/debugger-protocol/controller";
|
|
7
7
|
import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT } from "@unbound-app/debugger-protocol";
|
|
8
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
10
|
+
import { API_KEY_HEADER, ApiRequestError, CLI_CLIENT_ID, createApiClient } from "@unbound-app/web/client";
|
|
11
|
+
import toError from "@unbound-app/utils/to-error";
|
|
12
12
|
import { glob } from "node:fs/promises";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
|
+
import { addonManifestSchema } from "@unbound-app/contracts/manifest";
|
|
15
|
+
import { publishArtifact } from "@unbound-app/web/publish";
|
|
16
|
+
import { apiUrlSchema } from "@unbound-app/contracts/api";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
|
|
19
|
+
import { deviceAuthorizationClient } from "better-auth/client/plugins";
|
|
20
|
+
import { createAuthClient } from "better-auth/client";
|
|
13
21
|
import pc from "picocolors";
|
|
14
22
|
//#region src/lib/stdin.ts
|
|
15
23
|
/**
|
|
@@ -33,7 +41,7 @@ async function readStdin() {
|
|
|
33
41
|
//#region src/lib/config.ts
|
|
34
42
|
/** The filename walked up the directory tree to locate a workspace root. */
|
|
35
43
|
const CONFIG_FILENAME = "unbound.config.json";
|
|
36
|
-
const schema = z.object({
|
|
44
|
+
const schema$2 = z.object({
|
|
37
45
|
$schema: z.string().optional(),
|
|
38
46
|
addons: z.array(z.string()).default(["plugins/*", "themes/*"]),
|
|
39
47
|
build: z.string().default("bun run build"),
|
|
@@ -49,13 +57,13 @@ const schema = z.object({
|
|
|
49
57
|
function loadConfig(cwd = process.cwd()) {
|
|
50
58
|
const found = findConfigFile(cwd);
|
|
51
59
|
if (!found) return {
|
|
52
|
-
config: schema.parse({}),
|
|
60
|
+
config: schema$2.parse({}),
|
|
53
61
|
dir: resolve(cwd),
|
|
54
62
|
bare: true
|
|
55
63
|
};
|
|
56
64
|
const raw = JSON.parse(readFileSync(found, "utf8"));
|
|
57
65
|
return {
|
|
58
|
-
config: schema.parse(raw),
|
|
66
|
+
config: schema$2.parse(raw),
|
|
59
67
|
dir: dirname(found),
|
|
60
68
|
bare: false
|
|
61
69
|
};
|
|
@@ -66,7 +74,7 @@ function findConfigFile(from) {
|
|
|
66
74
|
const candidate = resolve(dir, CONFIG_FILENAME);
|
|
67
75
|
if (existsSync(candidate)) return candidate;
|
|
68
76
|
const parent = dirname(dir);
|
|
69
|
-
if (parent === dir) return
|
|
77
|
+
if (parent === dir) return;
|
|
70
78
|
dir = parent;
|
|
71
79
|
}
|
|
72
80
|
}
|
|
@@ -251,6 +259,292 @@ function toMcpTool(server, definition, context) {
|
|
|
251
259
|
});
|
|
252
260
|
}
|
|
253
261
|
//#endregion
|
|
262
|
+
//#region src/lib/discovery.ts
|
|
263
|
+
/**
|
|
264
|
+
* @description Discovers every addon reachable through the config's `addons` globs. A directory is an
|
|
265
|
+
* addon iff it contains a `manifest.json`; its kind is derived from the manifest `main` (a `.json`
|
|
266
|
+
* entry point is a theme, anything else a plugin) and it is static when its directory carries no
|
|
267
|
+
* `package.json` with a `build` script.
|
|
268
|
+
* @param resolved The loaded config paired with the directory its globs resolve relative to.
|
|
269
|
+
* @returns Every discovered addon, keyed implicitly by directory.
|
|
270
|
+
*/
|
|
271
|
+
async function discoverAddons(resolved) {
|
|
272
|
+
const { config, dir, bare } = resolved;
|
|
273
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
274
|
+
if (bare) dirs.add(dir);
|
|
275
|
+
else for (const pattern of config.addons) for await (const match of glob(pattern, { cwd: dir })) dirs.add(resolve(dir, match));
|
|
276
|
+
const addons = [];
|
|
277
|
+
for (const addonDir of dirs) {
|
|
278
|
+
const addon = readAddon(addonDir, config.output);
|
|
279
|
+
if (addon) addons.push(addon);
|
|
280
|
+
}
|
|
281
|
+
return addons;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* @description Reads a single directory as an addon, returning `undefined` when it has no
|
|
285
|
+
* `manifest.json` or the manifest lacks the fields discovery needs.
|
|
286
|
+
* @param addonDir The absolute directory to inspect.
|
|
287
|
+
* @param output The config's output directory, relative to the addon.
|
|
288
|
+
* @returns The discovered addon, or `undefined` if the directory is not an addon.
|
|
289
|
+
*/
|
|
290
|
+
function readAddon(addonDir, output) {
|
|
291
|
+
const manifestPath = join(addonDir, "manifest.json");
|
|
292
|
+
if (!existsSync(manifestPath)) return;
|
|
293
|
+
let manifest;
|
|
294
|
+
try {
|
|
295
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
296
|
+
} catch (error) {
|
|
297
|
+
process.stderr.write(`Skipping ${addonDir}: its manifest.json could not be read: ${String(error)}\n`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (typeof manifest.id !== "string" || typeof manifest.main !== "string") return;
|
|
301
|
+
const kind = manifest.main.endsWith(".json") ? "theme" : "plugin";
|
|
302
|
+
const isStatic = !hasBuildScript(addonDir);
|
|
303
|
+
return {
|
|
304
|
+
id: manifest.id,
|
|
305
|
+
kind,
|
|
306
|
+
dir: addonDir,
|
|
307
|
+
output: isStatic ? addonDir : join(addonDir, output),
|
|
308
|
+
static: isStatic
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function hasBuildScript(addonDir) {
|
|
312
|
+
const packagePath = join(addonDir, "package.json");
|
|
313
|
+
if (!existsSync(packagePath)) return false;
|
|
314
|
+
try {
|
|
315
|
+
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
316
|
+
return Boolean(pkg.scripts?.build);
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/lib/build-addon.ts
|
|
323
|
+
/**
|
|
324
|
+
* @description Builds a single addon by running the config's `build` command with cwd set to the
|
|
325
|
+
* addon directory. Static addons carry no build step, so they resolve immediately as successful.
|
|
326
|
+
* stdout and stderr are inherited so the build's own output surfaces live.
|
|
327
|
+
* @param addon The addon to build.
|
|
328
|
+
* @param buildCommand The shell command to run in the addon directory.
|
|
329
|
+
* @returns Whether the build succeeded (always `true` for static addons).
|
|
330
|
+
*/
|
|
331
|
+
function buildAddon(addon, buildCommand) {
|
|
332
|
+
if (addon.static) return Promise.resolve(true);
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
const child = spawn(buildCommand, {
|
|
335
|
+
cwd: addon.dir,
|
|
336
|
+
shell: true,
|
|
337
|
+
stdio: "inherit"
|
|
338
|
+
});
|
|
339
|
+
child.on("error", () => resolve(false));
|
|
340
|
+
child.on("close", (code) => resolve(code === 0));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/lib/bundle-name.ts
|
|
345
|
+
/**
|
|
346
|
+
* @description Names an addon's bundle inside its output directory. A static addon ships the file
|
|
347
|
+
* named by `main` as-is; a built addon's bundle is the JavaScript the build emits for `main`.
|
|
348
|
+
* @param addon The addon.
|
|
349
|
+
* @param main The manifest's `main`.
|
|
350
|
+
* @returns The bundle's path relative to the addon's output directory.
|
|
351
|
+
*/
|
|
352
|
+
function getBundleName(addon, main) {
|
|
353
|
+
return addon.static ? main : basename(main).replace(/\.\w+$/, ".js");
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/lib/env.ts
|
|
357
|
+
const schema$1 = z.object({
|
|
358
|
+
/** The origin of the Unbound web API that `ubd login` and `ubd publish` talk to. */
|
|
359
|
+
UNBOUND_API_URL: apiUrlSchema,
|
|
360
|
+
/** An API key for CI; when set, `ubd publish` sends it instead of the stored login. */
|
|
361
|
+
UNBOUND_API_KEY: z.string().min(1).optional(),
|
|
362
|
+
XDG_CONFIG_HOME: z.string().min(1).optional()
|
|
363
|
+
});
|
|
364
|
+
/**
|
|
365
|
+
* @description Parses the environment the account commands read, throwing a readable error when a
|
|
366
|
+
* variable is invalid.
|
|
367
|
+
* @param source The variables to parse.
|
|
368
|
+
* @returns The validated environment.
|
|
369
|
+
*/
|
|
370
|
+
function parseEnv(source = process.env) {
|
|
371
|
+
const parsed = schema$1.safeParse(source);
|
|
372
|
+
if (!parsed.success) throw new Error(`Invalid environment:\n${z.prettifyError(parsed.error)}`);
|
|
373
|
+
return parsed.data;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* @description Resolves where `ubd login` stores credentials, following the XDG base directory spec.
|
|
377
|
+
* @param env The validated environment.
|
|
378
|
+
* @returns The absolute path of `unbound/credentials.json` in the user's config directory.
|
|
379
|
+
*/
|
|
380
|
+
function getCredentialsPath(env) {
|
|
381
|
+
const configHome = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
382
|
+
return join(configHome, "unbound", "credentials.json");
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/lib/credentials.ts
|
|
386
|
+
const schema = z.object({
|
|
387
|
+
/** The API origin the session belongs to; it is never sent to any other origin. */
|
|
388
|
+
apiUrl: z.url(),
|
|
389
|
+
/** The Better Auth session token from the device authorization flow, sent as a bearer token. */
|
|
390
|
+
token: z.string().min(1)
|
|
391
|
+
});
|
|
392
|
+
/**
|
|
393
|
+
* @description Reads the stored credentials, throwing when the file exists but is not valid.
|
|
394
|
+
* @param path The credentials file.
|
|
395
|
+
* @returns The credentials, or `undefined` when nobody is logged in.
|
|
396
|
+
*/
|
|
397
|
+
function readCredentials(path) {
|
|
398
|
+
if (!existsSync(path)) return;
|
|
399
|
+
const parsed = schema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
400
|
+
if (!parsed.success) throw new Error(`Invalid credentials in ${path}:\n${z.prettifyError(parsed.error)}`);
|
|
401
|
+
return parsed.data;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* @description Stores credentials readable by the current user only.
|
|
405
|
+
* @param path The credentials file.
|
|
406
|
+
* @param credentials The session to store.
|
|
407
|
+
*/
|
|
408
|
+
function writeCredentials(path, credentials) {
|
|
409
|
+
mkdirSync(dirname(path), {
|
|
410
|
+
recursive: true,
|
|
411
|
+
mode: 448
|
|
412
|
+
});
|
|
413
|
+
rmSync(path, { force: true });
|
|
414
|
+
writeFileSync(path, `${JSON.stringify(credentials, null, " ")}\n`, { mode: 384 });
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* @description Deletes the stored credentials.
|
|
418
|
+
* @param path The credentials file.
|
|
419
|
+
* @returns Whether there were credentials to delete.
|
|
420
|
+
*/
|
|
421
|
+
function removeCredentials(path) {
|
|
422
|
+
if (!existsSync(path)) return false;
|
|
423
|
+
rmSync(path);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/lib/api-client.ts
|
|
428
|
+
/**
|
|
429
|
+
* @description Builds the credentials the API is called with: the `UNBOUND_API_KEY` when set (CI),
|
|
430
|
+
* otherwise the session `ubd login` stored for this API origin.
|
|
431
|
+
* @param env The validated environment.
|
|
432
|
+
* @returns The authentication headers.
|
|
433
|
+
*/
|
|
434
|
+
function getAuthHeaders(env) {
|
|
435
|
+
if (env.UNBOUND_API_KEY) return new Headers({ [API_KEY_HEADER]: env.UNBOUND_API_KEY });
|
|
436
|
+
const credentials = readCredentials(getCredentialsPath(env));
|
|
437
|
+
if (!credentials) throw new Error("Not logged in. Run `ubd login`, or set UNBOUND_API_KEY.");
|
|
438
|
+
if (credentials.apiUrl !== env.UNBOUND_API_URL) throw new Error(`Logged in to ${credentials.apiUrl}, not ${env.UNBOUND_API_URL}. Run \`ubd login\` again.`);
|
|
439
|
+
return new Headers({ authorization: `Bearer ${credentials.token}` });
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* @description Creates the typed Unbound API client, authenticated as {@link getAuthHeaders} resolves.
|
|
443
|
+
* @param env The validated environment.
|
|
444
|
+
* @returns The typed `request(route, options)` function.
|
|
445
|
+
*/
|
|
446
|
+
function createClient(env) {
|
|
447
|
+
return createApiClient({
|
|
448
|
+
baseUrl: env.UNBOUND_API_URL,
|
|
449
|
+
headers: getAuthHeaders(env)
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/lib/publish.ts
|
|
454
|
+
function readArtifact(addon) {
|
|
455
|
+
const path = join(addon.dir, "manifest.json");
|
|
456
|
+
const parsed = addonManifestSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
457
|
+
if (!parsed.success) throw new Error(`Invalid ${path}:\n${z.prettifyError(parsed.error)}`);
|
|
458
|
+
const main = getBundleName(addon, parsed.data.main);
|
|
459
|
+
return {
|
|
460
|
+
manifest: {
|
|
461
|
+
...parsed.data,
|
|
462
|
+
main
|
|
463
|
+
},
|
|
464
|
+
bundle: Bun.file(join(addon.output, main))
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* @description Publishes a built addon for review: opens a submission (the server refuses it, before
|
|
469
|
+
* anything is uploaded, when the bundle is over its size limit or a spam gate applies), uploads the
|
|
470
|
+
* bundle to the presigned URL and finalizes the submission.
|
|
471
|
+
* @param options The environment, the addon and the submission details.
|
|
472
|
+
* @returns The submission, pending review.
|
|
473
|
+
*/
|
|
474
|
+
async function publish({ env, addon, repository, changelog }) {
|
|
475
|
+
const { manifest, bundle } = readArtifact(addon);
|
|
476
|
+
if (!await bundle.exists()) throw new Error(`Built bundle not found at ${bundle.name}. Build ${addon.id} first.`);
|
|
477
|
+
return publishArtifact(createClient(env), {
|
|
478
|
+
manifest,
|
|
479
|
+
bundle,
|
|
480
|
+
repository,
|
|
481
|
+
changelog
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/commands/publish.ts
|
|
486
|
+
const publishCommand = {
|
|
487
|
+
name: "publish",
|
|
488
|
+
description: "Build an addon and submit it for review on Unbound. Authenticates with UNBOUND_API_KEY when set, otherwise with the `ubd login` session.",
|
|
489
|
+
surfaces: {
|
|
490
|
+
cli: true,
|
|
491
|
+
mcp: false
|
|
492
|
+
},
|
|
493
|
+
schema: z.object({
|
|
494
|
+
addon: z.string().optional().describe("The id of the addon to publish; required when the workspace has several."),
|
|
495
|
+
skipBuild: z.boolean().default(false).describe("Publish the existing build output instead of building first."),
|
|
496
|
+
repository: z.string().optional().describe("The GitHub owner/name the addon is built from, to verify its source."),
|
|
497
|
+
changelog: z.string().optional().describe("What changed in this version.")
|
|
498
|
+
}),
|
|
499
|
+
async handler({ addon: id, skipBuild, repository, changelog }) {
|
|
500
|
+
try {
|
|
501
|
+
const env = parseEnv();
|
|
502
|
+
const resolved = loadConfig();
|
|
503
|
+
const addon = selectAddon(await discoverAddons(resolved), id);
|
|
504
|
+
if (!skipBuild && !await buildAddon(addon, resolved.config.build)) return {
|
|
505
|
+
ok: false,
|
|
506
|
+
text: `Failed to build ${addon.id}.`
|
|
507
|
+
};
|
|
508
|
+
const submission = await publish({
|
|
509
|
+
env,
|
|
510
|
+
addon,
|
|
511
|
+
repository,
|
|
512
|
+
changelog
|
|
513
|
+
});
|
|
514
|
+
const source = submission.unverifiedSource ? " (unverified source)" : "";
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
text: `Submitted ${submission.addonId} ${submission.version} as ${submission.kind} ${submission.id}${source}: ${submission.status}.`,
|
|
518
|
+
data: submission
|
|
519
|
+
};
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return toFailure(error);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
function selectAddon(addons, id) {
|
|
526
|
+
const ids = addons.map((addon) => addon.id).join(", ");
|
|
527
|
+
if (id) {
|
|
528
|
+
const found = addons.find((addon) => addon.id === id);
|
|
529
|
+
if (!found) throw new Error(`No addon ${id} in this workspace. Found: ${ids || "none"}.`);
|
|
530
|
+
return found;
|
|
531
|
+
}
|
|
532
|
+
const [only] = addons;
|
|
533
|
+
if (!only) throw new Error("No addons found.");
|
|
534
|
+
if (addons.length > 1) throw new Error(`Several addons found (${ids}); choose one with --addon.`);
|
|
535
|
+
return only;
|
|
536
|
+
}
|
|
537
|
+
function toFailure(error) {
|
|
538
|
+
if (error instanceof ApiRequestError) return {
|
|
539
|
+
ok: false,
|
|
540
|
+
text: `${error.message} (${error.code})`
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
text: toError(error).message
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
//#endregion
|
|
254
548
|
//#region src/lib/templates.ts
|
|
255
549
|
/** The base of every documentation link surfaced by `ubd create`. */
|
|
256
550
|
const DOCS_BASE_URL = "https://docs.unbound.rip";
|
|
@@ -594,87 +888,34 @@ function printOutro(result) {
|
|
|
594
888
|
outro(`${result.name} ready.`);
|
|
595
889
|
}
|
|
596
890
|
//#endregion
|
|
597
|
-
//#region src/
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
async
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
* @returns The discovered addon, or `undefined` if the directory is not an addon.
|
|
624
|
-
*/
|
|
625
|
-
function readAddon(addonDir, output) {
|
|
626
|
-
const manifestPath = join(addonDir, "manifest.json");
|
|
627
|
-
if (!existsSync(manifestPath)) return void 0;
|
|
628
|
-
let manifest;
|
|
629
|
-
try {
|
|
630
|
-
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
631
|
-
} catch (error) {
|
|
632
|
-
process.stderr.write(`Skipping ${addonDir}: its manifest.json could not be read: ${error}\n`);
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
if (typeof manifest.id !== "string" || typeof manifest.main !== "string") return void 0;
|
|
636
|
-
const kind = manifest.main.endsWith(".json") ? "theme" : "plugin";
|
|
637
|
-
const isStatic = !hasBuildScript(addonDir);
|
|
638
|
-
return {
|
|
639
|
-
id: manifest.id,
|
|
640
|
-
kind,
|
|
641
|
-
dir: addonDir,
|
|
642
|
-
output: isStatic ? addonDir : join(addonDir, output),
|
|
643
|
-
static: isStatic
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
function hasBuildScript(addonDir) {
|
|
647
|
-
const packagePath = join(addonDir, "package.json");
|
|
648
|
-
if (!existsSync(packagePath)) return false;
|
|
649
|
-
try {
|
|
650
|
-
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
651
|
-
return Boolean(pkg.scripts?.build);
|
|
652
|
-
} catch {
|
|
653
|
-
return false;
|
|
891
|
+
//#region src/commands/logout.ts
|
|
892
|
+
const logoutCommand = {
|
|
893
|
+
name: "logout",
|
|
894
|
+
description: "Delete the credentials `ubd login` stored.",
|
|
895
|
+
surfaces: {
|
|
896
|
+
cli: true,
|
|
897
|
+
mcp: false
|
|
898
|
+
},
|
|
899
|
+
schema: z.object({}),
|
|
900
|
+
async handler() {
|
|
901
|
+
try {
|
|
902
|
+
const path = getCredentialsPath(parseEnv());
|
|
903
|
+
if (!removeCredentials(path)) return {
|
|
904
|
+
ok: true,
|
|
905
|
+
text: "Not logged in."
|
|
906
|
+
};
|
|
907
|
+
return {
|
|
908
|
+
ok: true,
|
|
909
|
+
text: `Logged out. Deleted ${path}.`
|
|
910
|
+
};
|
|
911
|
+
} catch (error) {
|
|
912
|
+
return {
|
|
913
|
+
ok: false,
|
|
914
|
+
text: toError(error).message
|
|
915
|
+
};
|
|
916
|
+
}
|
|
654
917
|
}
|
|
655
|
-
}
|
|
656
|
-
//#endregion
|
|
657
|
-
//#region src/lib/build-addon.ts
|
|
658
|
-
/**
|
|
659
|
-
* @description Builds a single addon by running the config's `build` command with cwd set to the
|
|
660
|
-
* addon directory. Static addons carry no build step, so they resolve immediately as successful.
|
|
661
|
-
* stdout and stderr are inherited so the build's own output surfaces live.
|
|
662
|
-
* @param addon The addon to build.
|
|
663
|
-
* @param buildCommand The shell command to run in the addon directory.
|
|
664
|
-
* @returns Whether the build succeeded (always `true` for static addons).
|
|
665
|
-
*/
|
|
666
|
-
function buildAddon(addon, buildCommand) {
|
|
667
|
-
if (addon.static) return Promise.resolve(true);
|
|
668
|
-
return new Promise((resolve) => {
|
|
669
|
-
const child = spawn(buildCommand, {
|
|
670
|
-
cwd: addon.dir,
|
|
671
|
-
shell: true,
|
|
672
|
-
stdio: "inherit"
|
|
673
|
-
});
|
|
674
|
-
child.on("error", () => resolve(false));
|
|
675
|
-
child.on("close", (code) => resolve(code === 0));
|
|
676
|
-
});
|
|
677
|
-
}
|
|
918
|
+
};
|
|
678
919
|
//#endregion
|
|
679
920
|
//#region src/commands/build.ts
|
|
680
921
|
const buildCommand = {
|
|
@@ -716,6 +957,90 @@ const buildCommand = {
|
|
|
716
957
|
}
|
|
717
958
|
};
|
|
718
959
|
//#endregion
|
|
960
|
+
//#region src/lib/login.ts
|
|
961
|
+
/** The grant type of an RFC 8628 device access token request. */
|
|
962
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
963
|
+
/** How much RFC 8628 says to lengthen the polling interval on every `slow_down`. */
|
|
964
|
+
const SLOW_DOWN_STEP_MS = 5e3;
|
|
965
|
+
/**
|
|
966
|
+
* @description Logs in through Better Auth's device authorization flow: requests a code, shows it,
|
|
967
|
+
* polls at the server's interval until the user approves it on the website, and stores the session.
|
|
968
|
+
* @param options The environment, the polling delay and how to show the code.
|
|
969
|
+
* @returns The path the credentials were stored at.
|
|
970
|
+
*/
|
|
971
|
+
async function login({ env, delay, prompt }) {
|
|
972
|
+
const auth = createAuthClient({
|
|
973
|
+
baseURL: env.UNBOUND_API_URL,
|
|
974
|
+
plugins: [deviceAuthorizationClient()]
|
|
975
|
+
});
|
|
976
|
+
const code = await auth.device.code({ client_id: CLI_CLIENT_ID });
|
|
977
|
+
if (code.error) throw new Error(`Could not start the login: ${code.error.error_description} (${code.error.error}).`);
|
|
978
|
+
prompt({
|
|
979
|
+
userCode: code.data.user_code,
|
|
980
|
+
verificationUri: code.data.verification_uri,
|
|
981
|
+
verificationUriComplete: code.data.verification_uri_complete
|
|
982
|
+
});
|
|
983
|
+
let interval = code.data.interval * 1e3;
|
|
984
|
+
while (true) {
|
|
985
|
+
await delay(interval);
|
|
986
|
+
const token = await auth.device.token({
|
|
987
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
988
|
+
device_code: code.data.device_code,
|
|
989
|
+
client_id: CLI_CLIENT_ID
|
|
990
|
+
});
|
|
991
|
+
if (token.data) {
|
|
992
|
+
const path = getCredentialsPath(env);
|
|
993
|
+
writeCredentials(path, {
|
|
994
|
+
apiUrl: env.UNBOUND_API_URL,
|
|
995
|
+
token: token.data.access_token
|
|
996
|
+
});
|
|
997
|
+
return path;
|
|
998
|
+
}
|
|
999
|
+
if (token.error.error === "authorization_pending") continue;
|
|
1000
|
+
if (token.error.error === "slow_down") {
|
|
1001
|
+
interval += SLOW_DOWN_STEP_MS;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
throw new Error(`Login failed: ${token.error.error_description} (${token.error.error}).`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
//#endregion
|
|
1008
|
+
//#region src/commands/login.ts
|
|
1009
|
+
const loginCommand = {
|
|
1010
|
+
name: "login",
|
|
1011
|
+
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.",
|
|
1012
|
+
surfaces: {
|
|
1013
|
+
cli: true,
|
|
1014
|
+
mcp: false
|
|
1015
|
+
},
|
|
1016
|
+
schema: z.object({}),
|
|
1017
|
+
async handler() {
|
|
1018
|
+
try {
|
|
1019
|
+
const env = parseEnv();
|
|
1020
|
+
if (env.UNBOUND_API_KEY) return {
|
|
1021
|
+
ok: true,
|
|
1022
|
+
text: "UNBOUND_API_KEY is set, so ubd uses it and needs no login."
|
|
1023
|
+
};
|
|
1024
|
+
return {
|
|
1025
|
+
ok: true,
|
|
1026
|
+
text: `Logged in. Credentials stored in ${await login({
|
|
1027
|
+
env,
|
|
1028
|
+
delay: Bun.sleep,
|
|
1029
|
+
prompt: printCode
|
|
1030
|
+
})}.`
|
|
1031
|
+
};
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
text: toError(error).message
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
function printCode(code) {
|
|
1041
|
+
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`);
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
719
1044
|
//#region src/lib/format.ts
|
|
720
1045
|
/**
|
|
721
1046
|
* @description Renders an {@link EvalResult} into the human-readable transcript both surfaces print:
|
|
@@ -769,9 +1094,8 @@ const evalCommand = {
|
|
|
769
1094
|
//#endregion
|
|
770
1095
|
//#region src/lib/reload-transport.ts
|
|
771
1096
|
/**
|
|
772
|
-
* How long the watcher waits for a dropped device to reconnect before
|
|
773
|
-
*
|
|
774
|
-
* hours, and a backgrounded app or slept device can take a few seconds to redial the bridge.
|
|
1097
|
+
* How long the watcher waits for a dropped device to reconnect before dropping a reload. Longer than
|
|
1098
|
+
* a one-shot command's poll, since a backgrounded app or slept device can take seconds to redial.
|
|
775
1099
|
*/
|
|
776
1100
|
const RECONNECT_WAIT_MS = 5e3;
|
|
777
1101
|
/**
|
|
@@ -807,7 +1131,7 @@ function createReloadTransport(context, reconnectWaitMs = RECONNECT_WAIT_MS) {
|
|
|
807
1131
|
if (result.ok) process.stdout.write(`Reloaded ${addon.id}.\n`);
|
|
808
1132
|
else process.stderr.write(`Failed to reload ${addon.id}: ${result.error}\n`);
|
|
809
1133
|
} catch (error) {
|
|
810
|
-
process.stderr.write(`Failed to reload ${addon.id}: ${error
|
|
1134
|
+
process.stderr.write(`Failed to reload ${addon.id}: ${toError(error).message}\n`);
|
|
811
1135
|
}
|
|
812
1136
|
} };
|
|
813
1137
|
}
|
|
@@ -820,7 +1144,7 @@ function createReloadTransport(context, reconnectWaitMs = RECONNECT_WAIT_MS) {
|
|
|
820
1144
|
*/
|
|
821
1145
|
function readBuiltAddon(addon) {
|
|
822
1146
|
const manifest = JSON.parse(readFileSync(join(addon.dir, "manifest.json"), "utf8"));
|
|
823
|
-
const bundleName = addon
|
|
1147
|
+
const bundleName = getBundleName(addon, manifest.main);
|
|
824
1148
|
const bundlePath = join(addon.output, bundleName);
|
|
825
1149
|
if (!existsSync(bundlePath)) throw new Error(`Built bundle not found at ${bundlePath}. Hot reload expects the build to emit ${bundleName} into ${addon.output}.`);
|
|
826
1150
|
return {
|
|
@@ -833,10 +1157,8 @@ function readBuiltAddon(addon) {
|
|
|
833
1157
|
/** How long file changes inside one addon settle before that addon rebuilds. */
|
|
834
1158
|
const DEBOUNCE_MS = 150;
|
|
835
1159
|
/**
|
|
836
|
-
* Watches every discovered addon and rebuilds only the
|
|
837
|
-
*
|
|
838
|
-
* removed while the server runs are picked up. After each successful build the reload transport is
|
|
839
|
-
* called; static addons skip the build step but still count as a successful build for reload.
|
|
1160
|
+
* Watches every discovered addon and rebuilds (debounced) and reloads only the one that changed.
|
|
1161
|
+
* Directory creates and deletes re-run discovery; static addons skip the build but still reload.
|
|
840
1162
|
*/
|
|
841
1163
|
var DevServer = class {
|
|
842
1164
|
resolved;
|
|
@@ -951,7 +1273,10 @@ const commands = [
|
|
|
951
1273
|
return new Promise(() => {});
|
|
952
1274
|
}
|
|
953
1275
|
},
|
|
954
|
-
createCommand
|
|
1276
|
+
createCommand,
|
|
1277
|
+
loginCommand,
|
|
1278
|
+
logoutCommand,
|
|
1279
|
+
publishCommand
|
|
955
1280
|
];
|
|
956
1281
|
//#endregion
|
|
957
1282
|
//#region src/commands/mcp.ts
|
|
@@ -989,6 +1314,9 @@ const SUMMARIES = {
|
|
|
989
1314
|
create: "Scaffold a new addon or workspace.",
|
|
990
1315
|
dev: "Watch addons and rebuild on change.",
|
|
991
1316
|
build: "Build every addon in the workspace.",
|
|
1317
|
+
publish: "Build an addon and submit it for review.",
|
|
1318
|
+
login: "Log in to Unbound for publishing.",
|
|
1319
|
+
logout: "Delete the stored login.",
|
|
992
1320
|
eval: "Run JavaScript on the connected device.",
|
|
993
1321
|
mcp: "Serve the debugger as an MCP server."
|
|
994
1322
|
};
|
|
@@ -997,6 +1325,9 @@ const ORDER = [
|
|
|
997
1325
|
"create",
|
|
998
1326
|
"dev",
|
|
999
1327
|
"build",
|
|
1328
|
+
"publish",
|
|
1329
|
+
"login",
|
|
1330
|
+
"logout",
|
|
1000
1331
|
"eval",
|
|
1001
1332
|
"mcp"
|
|
1002
1333
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unbound-app/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.",
|
|
@@ -22,13 +22,18 @@
|
|
|
22
22
|
"type": "module",
|
|
23
23
|
"module": "./dist/index.mjs",
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "tsdown"
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"typecheck": "bun run --filter @unbound-app/web build:types && tsc --noEmit -p ."
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"@clack/prompts": "^1.7.0",
|
|
29
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
30
|
-
"@unbound-app/
|
|
31
|
-
"@unbound-app/
|
|
31
|
+
"@unbound-app/contracts": "0.7.0",
|
|
32
|
+
"@unbound-app/debugger-protocol": "0.7.0",
|
|
33
|
+
"@unbound-app/types": "0.7.0",
|
|
34
|
+
"@unbound-app/utils": "0.7.0",
|
|
35
|
+
"@unbound-app/web": "0.7.0",
|
|
36
|
+
"better-auth": "1.7.10",
|
|
32
37
|
"cleye": "^1.3.4",
|
|
33
38
|
"picocolors": "^1.1.1",
|
|
34
39
|
"zod": "^4.0.0"
|