@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.
Files changed (2) hide show
  1. package/dist/index.mjs +428 -97
  2. 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 { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
11
- import { spawn, spawnSync } from "node:child_process";
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 void 0;
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/lib/discovery.ts
598
- /**
599
- * @description Discovers every addon reachable through the config's `addons` globs. A directory is an
600
- * addon iff it contains a `manifest.json`; its kind is derived from the manifest `main` (a `.json`
601
- * entry point is a theme, anything else a plugin) and it is static when its directory carries no
602
- * `package.json` with a `build` script.
603
- * @param resolved The loaded config paired with the directory its globs resolve relative to.
604
- * @returns Every discovered addon, keyed implicitly by directory.
605
- */
606
- async function discoverAddons(resolved) {
607
- const { config, dir, bare } = resolved;
608
- const dirs = /* @__PURE__ */ new Set();
609
- if (bare) dirs.add(dir);
610
- else for (const pattern of config.addons) for await (const match of glob(pattern, { cwd: dir })) dirs.add(resolve(dir, match));
611
- const addons = [];
612
- for (const addonDir of dirs) {
613
- const addon = readAddon(addonDir, config.output);
614
- if (addon) addons.push(addon);
615
- }
616
- return addons;
617
- }
618
- /**
619
- * @description Reads a single directory as an addon, returning `undefined` when it has no
620
- * `manifest.json` or the manifest lacks the fields discovery needs.
621
- * @param addonDir The absolute directory to inspect.
622
- * @param output The config's output directory, relative to the addon.
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 warning and dropping a reload.
773
- * Longer than a one-shot command's poll (`DEVICE_WAIT_TIMEOUT_MS`): a `ubd dev` session runs for
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?.message ?? error}\n`);
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.static ? manifest.main : basename(manifest.main).replace(/\.\w+$/, ".js");
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 addon whose files changed, debounced per
837
- * addon. Directory creates and deletes under the workspace re-run discovery so addons added or
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.4.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/debugger-protocol": "0.4.0",
31
- "@unbound-app/types": "0.4.0",
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"