@unbound-app/cli 0.1.3 → 0.4.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 +69 -14
  2. package/package.json +4 -7
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { cli, command } from "cleye";
3
3
  import { z } from "zod";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
5
- import { dirname, join, relative, resolve, sep } from "node:path";
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";
@@ -439,7 +439,8 @@ function scaffoldTemplate(from, to, tokens) {
439
439
  mkdirSync(to, { recursive: true });
440
440
  for (const entry of readdirSync(from, { withFileTypes: true })) {
441
441
  const source = join(from, entry.name);
442
- const target = join(to, entry.name === GITIGNORE_SOURCE ? ".gitignore" : substitute(entry.name, tokens));
442
+ const name = entry.name === GITIGNORE_SOURCE ? ".gitignore" : substitute(entry.name, tokens);
443
+ const target = join(to, name);
443
444
  if (entry.isDirectory()) {
444
445
  scaffoldTemplate(source, target, tokens);
445
446
  continue;
@@ -583,12 +584,13 @@ const createCommand = {
583
584
  function printOutro(result) {
584
585
  const kindDocs = `${DOCS_BASE_URL}/${result.kind}s/introduction`;
585
586
  const manifestDocs = `${DOCS_BASE_URL}/addons/manifest`;
586
- note([
587
+ const steps = [
587
588
  `cd ${relative(process.cwd(), result.dir) || "."}`,
588
589
  "ubd dev",
589
590
  `Learn the ${result.kind} API: ${kindDocs}`,
590
591
  `Manifest reference: ${manifestDocs}`
591
- ].join("\n"), "Next steps");
592
+ ];
593
+ note(steps.join("\n"), "Next steps");
592
594
  outro(`${result.name} ready.`);
593
595
  }
594
596
  //#endregion
@@ -767,13 +769,65 @@ const evalCommand = {
767
769
  //#endregion
768
770
  //#region src/lib/reload-transport.ts
769
771
  /**
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`.
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.
773
775
  */
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
- } };
776
+ const RECONNECT_WAIT_MS = 5e3;
777
+ /**
778
+ * @description Builds the real hot-reload transport over the shared bridge controller. Each reload
779
+ * reads the freshly built bundle and manifest from disk and pushes them to the device via
780
+ * {@link ControllerClient.pushPlugin}. Theme hot reload isn't wired yet, so a theme is skipped with a
781
+ * notice. If the device isn't connected it waits briefly for a reconnect, then warns and drops the
782
+ * reload; any disk or transport failure is reported. Nothing here throws, so the watcher keeps running.
783
+ * @param context A bearer of the bridge client shared by every command; its client dials on first use.
784
+ * @param reconnectWaitMs How long to wait for a dropped device to reconnect before dropping a reload.
785
+ * @returns A {@link ReloadTransport} bound to that context.
786
+ */
787
+ function createReloadTransport(context, reconnectWaitMs = RECONNECT_WAIT_MS) {
788
+ return { async reload(addon) {
789
+ if (addon.kind !== "plugin") {
790
+ process.stdout.write(`Rebuilt ${addon.id}, but ${addon.kind} hot reload isn't available yet — restart the app to load it.\n`);
791
+ return;
792
+ }
793
+ const { client } = context;
794
+ if (!client.isDeviceConnected) await waitForDevice(client, reconnectWaitMs);
795
+ if (!client.isDeviceConnected) {
796
+ process.stderr.write(`Device not connected — skipping reload of ${addon.id}; save again once reconnected.\n`);
797
+ return;
798
+ }
799
+ try {
800
+ const { manifest, bundle } = readBuiltAddon(addon);
801
+ process.stdout.write(`Pushing ${addon.id}…\n`);
802
+ const result = await client.pushPlugin({
803
+ addonId: addon.id,
804
+ bundle,
805
+ manifest
806
+ });
807
+ if (result.ok) process.stdout.write(`Reloaded ${addon.id}.\n`);
808
+ else process.stderr.write(`Failed to reload ${addon.id}: ${result.error}\n`);
809
+ } catch (error) {
810
+ process.stderr.write(`Failed to reload ${addon.id}: ${error?.message ?? error}\n`);
811
+ }
812
+ } };
813
+ }
814
+ /**
815
+ * @description Reads a built addon's manifest and bundle off disk. A static addon ships its files
816
+ * as-is, so its bundle is the file named by `main` in its directory; a built addon's bundle is the
817
+ * JavaScript the build emitted into the output directory, named after `main` with a `.js` extension.
818
+ * @param addon The freshly built addon.
819
+ * @returns The parsed manifest and the bundle source.
820
+ */
821
+ function readBuiltAddon(addon) {
822
+ const manifest = JSON.parse(readFileSync(join(addon.dir, "manifest.json"), "utf8"));
823
+ const bundleName = addon.static ? manifest.main : basename(manifest.main).replace(/\.\w+$/, ".js");
824
+ const bundlePath = join(addon.output, bundleName);
825
+ if (!existsSync(bundlePath)) throw new Error(`Built bundle not found at ${bundlePath}. Hot reload expects the build to emit ${bundleName} into ${addon.output}.`);
826
+ return {
827
+ manifest,
828
+ bundle: readFileSync(bundlePath, "utf8")
829
+ };
830
+ }
777
831
  //#endregion
778
832
  //#region src/lib/dev-server.ts
779
833
  /** How long file changes inside one addon settle before that addon rebuilds. */
@@ -866,7 +920,8 @@ function toBuiltAddon(addon) {
866
920
  id: addon.id,
867
921
  kind: addon.kind,
868
922
  dir: addon.dir,
869
- output: addon.output
923
+ output: addon.output,
924
+ static: addon.static
870
925
  };
871
926
  }
872
927
  //#endregion
@@ -885,9 +940,9 @@ const commands = [
885
940
  cli: true,
886
941
  mcp: false
887
942
  },
888
- schema: z.object({}),
889
- async handler() {
890
- const server = new DevServer(loadConfig(), stubReloadTransport);
943
+ schema: z.object({ port: z.number().default(DEFAULT_BRIDGE_PORT).describe("The port the debugger bridge listens on.") }),
944
+ async handler(_input, context) {
945
+ const server = new DevServer(loadConfig(), createReloadTransport(context));
891
946
  process.on("SIGINT", () => {
892
947
  server.stop();
893
948
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unbound-app/cli",
3
- "version": "0.1.3",
3
+ "version": "0.4.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.",
@@ -13,7 +13,7 @@
13
13
  "directory": "packages/cli"
14
14
  },
15
15
  "bin": {
16
- "ubd": "./dist/index.mjs"
16
+ "ubd": "dist/index.mjs"
17
17
  },
18
18
  "files": [
19
19
  "dist",
@@ -27,15 +27,12 @@
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
29
  "@modelcontextprotocol/sdk": "^1.29.0",
30
- "@unbound-app/debugger-protocol": "0.1.3",
30
+ "@unbound-app/debugger-protocol": "0.4.0",
31
+ "@unbound-app/types": "0.4.0",
31
32
  "cleye": "^1.3.4",
32
33
  "picocolors": "^1.1.1",
33
34
  "zod": "^4.0.0"
34
35
  },
35
- "devDependencies": {
36
- "@types/bun": "latest",
37
- "tsdown": "^0.22.9"
38
- },
39
36
  "peerDependencies": {
40
37
  "typescript": "^5.9.3"
41
38
  }