@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.4 → 0.1.2-dev.41

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 (51) hide show
  1. package/README.md +116 -16
  2. package/cli/README.md +5 -5
  3. package/cli/bin/create-muscat-plugin.mjs +1 -1
  4. package/cli/bin/muscat-pk.mjs +20 -10
  5. package/cli/bundle-templates.mjs +28 -0
  6. package/cli/docker/Dockerfile.bridge +17 -0
  7. package/cli/docker/Dockerfile.cpp +22 -0
  8. package/cli/docker/Dockerfile.dev +14 -0
  9. package/cli/docker/Dockerfile.python +20 -0
  10. package/cli/docker/bridge-package.json +8 -0
  11. package/cli/src/bridge.mjs +92 -0
  12. package/cli/src/build.mjs +398 -36
  13. package/cli/src/check-package.mjs +8 -23
  14. package/cli/src/cpp.mjs +61 -0
  15. package/cli/src/dev.mjs +16 -3
  16. package/cli/src/devBack.mjs +239 -53
  17. package/cli/src/devUi.mjs +118 -15
  18. package/cli/src/esbuild.mjs +51 -10
  19. package/cli/src/manifest.mjs +193 -0
  20. package/cli/src/new.mjs +168 -37
  21. package/cli/src/preview.mjs +91 -0
  22. package/cli/src/python.mjs +122 -0
  23. package/cli/src/ros2.mjs +90 -0
  24. package/cli/src/socketBridge.mjs +129 -0
  25. package/cli/src/templates-embedded.mjs +3 -0
  26. package/cli/src/type.mjs +105 -0
  27. package/cli/src/uninstall.mjs +98 -0
  28. package/cli/src/update.mjs +259 -30
  29. package/cli/src/util.mjs +97 -7
  30. package/cli/templates/common/README.md +383 -9
  31. package/cli/templates/common/manifest.json +13 -13
  32. package/cli/templates/common/package.json +5 -11
  33. package/cli/templates/common/py/main.py +88 -0
  34. package/cli/templates/common/requirements.txt +4 -0
  35. package/cli/templates/common/tsconfig.json +1 -1
  36. package/cli/templates/common/{src → ui}/content.tsx +3 -2
  37. package/cli/templates/common/vite.config.ts +22 -13
  38. package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
  39. package/cli/templates/cpp/cpp/main.cpp +25 -0
  40. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
  41. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
  42. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
  43. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
  44. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
  45. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
  46. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
  47. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
  48. package/package.json +4 -4
  49. package/cli/src/pack.mjs +0 -58
  50. package/cli/templates/sdk/main.py +0 -21
  51. package/cli/templates/sdk/requirements.txt +0 -1
@@ -1,65 +1,251 @@
1
- // muscat-pk dev:back — run the plugin back end.
2
- // sdk : create/refresh .venv then `python main.py`
3
- // ros2 : requires Docker (Jazzy container) — guidance only here
4
- // none : nothing to run
1
+ // muscat-pk dev:back — run the plugin back end during development.
2
+ // Every backend runs in a warm Docker container that matches the robot
3
+ // (Ubuntu 24.04), so dev behaves like production.
4
+ // python : Python 3.12 container, pip install, run main.py with watchfiles
5
+ // hot-reload. Shares the bridge container's netns (localhost:10000).
6
+ // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
+ // restart on source changes.
8
+ // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
+ // on source changes (rclcpp rebuilds on change).
5
10
  import { spawn, spawnSync } from "node:child_process";
6
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, watch } from "node:fs";
7
12
  import { resolve } from "node:path";
8
- import { findPluginRoot, log } from "./util.mjs";
9
-
10
- function manifestRuntime(root) {
11
- try {
12
- const m = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
13
- return m.runtime?.type ?? "none";
14
- } catch {
15
- return "none";
16
- }
13
+ import { ensureDocker, findPluginRoot, log } from "./util.mjs";
14
+ import { manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
15
+ import {
16
+ PYTHON_DEV_IMAGE,
17
+ assertDockerReady as pyAssertDockerReady,
18
+ ensureImage as pyEnsureImage,
19
+ } from "./python.mjs";
20
+ import {
21
+ ROS2_DEV_IMAGE,
22
+ assertDockerReady,
23
+ ensureImage,
24
+ ros2PackageName,
25
+ } from "./ros2.mjs";
26
+ import {
27
+ CPP_DEV_IMAGE,
28
+ assertDockerReady as cppAssertDockerReady,
29
+ ensureImage as cppEnsureImage,
30
+ } from "./cpp.mjs";
31
+
32
+ // ── python ────────────────────────────────────────────────────────────────────
33
+ // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
34
+ // Under `muscat-pk dev`, it shares the bridge container's netns, so the SDK
35
+ // reaches the Socket.IO bridge at the default localhost:10000.
36
+ async function runPython(root) {
37
+ pyAssertDockerReady();
38
+ await pyEnsureImage();
39
+
40
+ const manifest = readManifest(root);
41
+ const pluginId = manifest.id ?? "";
42
+ const container = `${PYTHON_DEV_IMAGE}-run`;
43
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
44
+ // Share the bridge container's network namespace so localhost:10000 is the
45
+ // bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
46
+ const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
47
+ const netFlags = bridgeContainer ? ["--network", `container:${bridgeContainer}`] : [];
48
+ const docker = (args, opts = {}) =>
49
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
50
+
51
+ // start a warm, long-lived container with the plugin mounted
52
+ docker(["rm", "-f", container], { stdio: "ignore" });
53
+ log.step("starting Python dev container…");
54
+ const up = docker(
55
+ ["run", "-d", "--name", container, ...netFlags,
56
+ "-e", "PIP_ROOT_USER_ACTION=ignore",
57
+ "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
58
+ { stdio: "inherit" },
59
+ );
60
+ if (up.status !== 0) throw new Error("failed to start dev container.");
61
+
62
+ log.step("pip install requirements + watchfiles (in container)…");
63
+ docker(["exec", container, "bash", "-lc",
64
+ "pip install --break-system-packages --ignore-installed -q watchfiles" +
65
+ " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
66
+
67
+ if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
68
+ log.step("watchfiles → py/main.py (reload on any .py change in py/)");
69
+ const child = spawn(
70
+ "docker",
71
+ ["exec", ...envFlags, container, "bash", "-lc",
72
+ "python3 -m watchfiles --filter python --target-type command 'python3 py/main.py' py"],
73
+ { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
74
+ );
75
+ // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
76
+ child.stderr.on("data", (buf) => {
77
+ const text = buf.toString();
78
+ if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
79
+ process.stderr.write(buf);
80
+ }
81
+ });
82
+
83
+ const cleanup = () => {
84
+ if (!child.killed) child.kill();
85
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
86
+ process.exit(0);
87
+ };
88
+ child.on("exit", (code) => {
89
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
90
+ process.exit(code ?? 0);
91
+ });
92
+ process.once("SIGINT", cleanup);
93
+ process.once("SIGTERM", cleanup);
17
94
  }
18
95
 
19
- export async function runDevBack() {
20
- const root = findPluginRoot();
21
- const type = manifestRuntime(root);
96
+ // ── ros2 ───────────────────────────────────────────────────────────────────────
97
+ async function runRos2(root) {
98
+ assertDockerReady();
99
+ const pkg = ros2PackageName(root);
100
+ if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
22
101
 
23
- if (type === "none") {
24
- log.info("runtime: none — no back end to run.");
25
- return;
26
- }
102
+ await ensureImage();
103
+
104
+ const manifest = readManifest(root);
105
+ const pluginId = manifest.id ?? "";
106
+ const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
107
+ const container = `${ROS2_DEV_IMAGE}-run`;
108
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
109
+ const docker = (args, opts = {}) =>
110
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
27
111
 
28
- if (type === "ros2") {
29
- log.warn(
30
- "ROS2 back end runs in a Jazzy + Python 3.12 Docker container.\n" +
31
- " Ensure Docker is running, then: muscat-pk dev:back (container support TBD).",
112
+ // start a warm, long-lived container with the workspace mounted
113
+ docker(["rm", "-f", container], { stdio: "ignore" });
114
+ log.step("starting ROS2 dev container…");
115
+ const up = docker(
116
+ ["run", "-d", "--name", container, "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
117
+ { stdio: "inherit" },
118
+ );
119
+ if (up.status !== 0) throw new Error("failed to start dev container.");
120
+
121
+ // one-time deps + symlink build (edits to .py reflect without rebuilding)
122
+ log.step("pip install + colcon build --symlink-install (once)…");
123
+ docker(["exec", container, "bash", "-lc",
124
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
125
+
126
+ let child = null;
127
+ const startNode = () => {
128
+ if (child) child.kill();
129
+ log.step(`ros2 run ${pkg} ${pkg}_node`);
130
+ child = spawn(
131
+ "docker",
132
+ ["exec", ...envFlags, container, "bash", "-lc",
133
+ `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
134
+ { cwd: root, stdio: "inherit" },
32
135
  );
33
- return;
34
- }
136
+ };
137
+ startNode();
35
138
 
36
- // python / rb_sdk
37
- const venv = resolve(root, ".venv");
38
- const py = process.platform === "win32"
39
- ? resolve(venv, "Scripts", "python.exe")
40
- : resolve(venv, "bin", "python");
41
-
42
- if (!existsSync(py)) {
43
- log.step("creating .venv (python3.12 preferred)…");
44
- const python = spawnSync("python3.12", ["--version"]).status === 0
45
- ? "python3.12"
46
- : "python3";
47
- if (python !== "python3.12") {
48
- log.warn("python3.12 not found; using python3 (robot targets 3.12).");
49
- }
50
- spawnSync(python, ["-m", "venv", venv], { cwd: root, stdio: "inherit" });
139
+ // restart node on source change. rclpy reflects .py edits live (symlink-install);
140
+ // rclcpp (C++) and package-metadata changes require a colcon rebuild.
141
+ let timer = null;
142
+ const onChange = (_event, filename) => {
143
+ clearTimeout(timer);
144
+ timer = setTimeout(() => {
145
+ const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
146
+ if (structural || isRclcpp) {
147
+ log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
148
+ docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
149
+ } else {
150
+ log.info("source changed — restarting node…");
151
+ }
152
+ startNode();
153
+ }, 300);
154
+ };
155
+ for (const target of ["ros2_ws/src", "main.py"]) {
156
+ const full = resolve(root, target);
157
+ if (existsSync(full)) watch(full, { recursive: true }, onChange);
51
158
  }
52
159
 
53
- const reqs = resolve(root, "requirements.txt");
54
- if (existsSync(reqs)) {
55
- log.step("pip install -r requirements.txt…");
56
- spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], {
57
- cwd: root,
58
- stdio: "inherit",
59
- });
60
- }
160
+ const cleanup = () => {
161
+ if (child) child.kill();
162
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
163
+ process.exit(0);
164
+ };
165
+ process.on("SIGINT", cleanup);
166
+ process.on("SIGTERM", cleanup);
167
+ }
168
+
169
+ // ── cpp ──────────────────────────────────────────────────────────────────────
170
+ async function runCpp(root) {
171
+ cppAssertDockerReady();
172
+ const manifest = readManifest(root);
173
+ const entry = manifest.runtime?.entry;
174
+ if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
175
+
176
+ await cppEnsureImage();
177
+
178
+ const pluginId = manifest.id ?? "";
179
+ const container = `${CPP_DEV_IMAGE}-run`;
180
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
181
+ const docker = (args, opts = {}) =>
182
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
183
+
184
+ // start a warm, long-lived container with the plugin mounted
185
+ docker(["rm", "-f", container], { stdio: "ignore" });
186
+ log.step("starting C++ dev container…");
187
+ const up = docker(
188
+ ["run", "-d", "--name", container, "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
189
+ { stdio: "inherit" },
190
+ );
191
+ if (up.status !== 0) throw new Error("failed to start dev container.");
192
+
193
+ const build = () => {
194
+ log.step("cmake build…");
195
+ return docker(["exec", container, "bash", "-lc",
196
+ "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
197
+ };
198
+ build();
199
+
200
+ let child = null;
201
+ const startBin = () => {
202
+ if (child) child.kill();
203
+ log.step(`run cpp/build/${entry}`);
204
+ child = spawn(
205
+ "docker",
206
+ ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
207
+ { cwd: root, stdio: "inherit" },
208
+ );
209
+ };
210
+ startBin();
61
211
 
62
- log.step("python main.py");
63
- const child = spawn(py, ["main.py"], { cwd: root, stdio: "inherit" });
64
- child.on("exit", (code) => process.exit(code ?? 0));
212
+ // rebuild + restart on C++ source changes; ignore the build/ output dir.
213
+ let timer = null;
214
+ const onChange = (_event, filename) => {
215
+ if (filename && String(filename).startsWith("build")) return;
216
+ clearTimeout(timer);
217
+ timer = setTimeout(() => {
218
+ log.info("source changed — rebuilding…");
219
+ const r = build();
220
+ if (r.status === 0) startBin();
221
+ else log.warn("build failed — keeping previous binary");
222
+ }, 300);
223
+ };
224
+ const cppDir = resolve(root, "cpp");
225
+ if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
226
+
227
+ const cleanup = () => {
228
+ if (child) child.kill();
229
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
230
+ process.exit(0);
231
+ };
232
+ process.on("SIGINT", cleanup);
233
+ process.on("SIGTERM", cleanup);
234
+ }
235
+
236
+ function warnManifest(root) {
237
+ const manifest = readManifest(root);
238
+ if (!manifest || Object.keys(manifest).length === 0) return;
239
+ const errors = validateManifest(manifest);
240
+ for (const e of errors) log.warn(`manifest.json: ${e}`);
241
+ }
242
+
243
+ export async function runDevBack() {
244
+ ensureDocker();
245
+ const root = findPluginRoot();
246
+ warnManifest(root);
247
+ const type = manifestRuntime(root);
248
+ if (type === "ros2") return await runRos2(root);
249
+ if (type === "cpp") return await runCpp(root);
250
+ return await runPython(root);
65
251
  }
package/cli/src/devUi.mjs CHANGED
@@ -1,37 +1,133 @@
1
1
  // muscat-pk dev:ui — preview the plugin inside a mini-Muscat (preview-host).
2
- import { spawn } from "node:child_process";
2
+ // UI bundling: vite build --watch (uses plugin project's own node_modules/vite).
3
+ // Falls back gracefully if Node.js or vite is not available.
4
+ import { spawn, spawnSync } from "node:child_process";
3
5
  import { createRequire } from "node:module";
4
- import { existsSync } from "node:fs";
6
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
5
8
  import { dirname, resolve } from "node:path";
6
- import { buildPlugin } from "./build.mjs";
7
- import { checkKitUpdate, findPluginRoot, kitRoot, log } from "./util.mjs";
9
+ import { SANDBOX_HTML } from "./build.mjs";
10
+ import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
11
+
12
+ const PREVIEW_PKG = "@rainbow-robotics/preview-host";
13
+ const CACHE_DIR = resolve(homedir(), ".muscat-pk");
14
+ const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
15
+
16
+ // Dev builds go to a throwaway dir, not dist/ — dist/ is reserved for
17
+ // build/pack output (the shippable artifact).
18
+ const DEV_OUT = ".muscat-dev";
19
+
20
+ function findNodeBin() {
21
+ if (typeof Bun === "undefined") return process.execPath;
22
+ for (const c of ["node", "node20", "node18"]) {
23
+ if (hasBin(c)) return c;
24
+ }
25
+ return null;
26
+ }
8
27
 
9
- /** Resolve @rainbow-robotics/preview-host package root.
10
- * Works in monorepo (yarn workspace hoisting) and when both packages are
11
- * installed as deps of the plugin project. */
12
28
  function findPreviewHostRoot() {
13
29
  try {
14
30
  const req = createRequire(import.meta.url);
15
- return dirname(req.resolve("@rainbow-robotics/preview-host/package.json"));
31
+ return dirname(req.resolve(`${PREVIEW_PKG}/package.json`));
32
+ } catch {}
33
+
34
+ const sibling = resolve(kitRoot, "../preview-host");
35
+ if (existsSync(resolve(sibling, "package.json"))) return sibling;
36
+
37
+ if (existsSync(resolve(CACHE_ROOT, "package.json"))) return CACHE_ROOT;
38
+
39
+ if (!hasBin("npm")) {
40
+ throw new Error(
41
+ `preview-host를 찾을 수 없습니다.\n` +
42
+ `Node.js 20+ 를 설치하면 자동으로 준비됩니다: https://nodejs.org`,
43
+ );
44
+ }
45
+ log.step(`preview-host 설치 중 (~/.muscat-pk) — 첫 실행 시 한 번만 수행됩니다…`);
46
+ mkdirSync(CACHE_DIR, { recursive: true });
47
+ const r = spawnSync(
48
+ "npm",
49
+ ["install", "--prefix", CACHE_DIR, "--save", PREVIEW_PKG],
50
+ { stdio: "inherit" },
51
+ );
52
+ if (r.status !== 0) throw new Error("preview-host 설치 실패.");
53
+ if (!existsSync(resolve(CACHE_ROOT, "package.json"))) {
54
+ throw new Error("preview-host 설치 후에도 패키지를 찾을 수 없습니다.");
55
+ }
56
+ return CACHE_ROOT;
57
+ }
58
+
59
+ // Write sandbox.html + copy manifest into the dev out dir (vite doesn't generate these).
60
+ function stageDevAssets(pluginRoot) {
61
+ const outDir = resolve(pluginRoot, DEV_OUT);
62
+ mkdirSync(outDir, { recursive: true });
63
+ writeFileSync(resolve(outDir, "sandbox.html"), SANDBOX_HTML, "utf8");
64
+ const manifestSrc = resolve(pluginRoot, "manifest.json");
65
+ if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(outDir, "manifest.json"));
66
+ return outDir;
67
+ }
68
+
69
+ // Resolve vite bin path from the plugin root, hoisting-aware.
70
+ function findViteBin(pluginRoot) {
71
+ try {
72
+ const req = createRequire(resolve(pluginRoot, "package.json"));
73
+ const vitePkg = req.resolve("vite/package.json");
74
+ return resolve(dirname(vitePkg), "bin", "vite.js");
16
75
  } catch {
17
- // fallback: sibling package in muscat-plugin-kit/ workspace
18
- return resolve(kitRoot, "../preview-host");
76
+ return null;
19
77
  }
20
78
  }
21
79
 
80
+ // Spawn vite build --watch as a background process.
81
+ // Returns the child process, or null if vite/node not available.
82
+ function spawnViteWatch(pluginRoot, node) {
83
+ const viteBin = findViteBin(pluginRoot);
84
+ if (!node || !viteBin || !existsSync(viteBin)) return null;
85
+
86
+ log.step(`vite build --watch (ui/content.tsx → ${DEV_OUT}/content.js)`);
87
+ return spawn(node, [viteBin, "build", "--watch", "--mode", "development", "--outDir", DEV_OUT], {
88
+ cwd: pluginRoot,
89
+ stdio: "inherit",
90
+ });
91
+ }
92
+
93
+ // Wait until dist/content.js exists (vite initial build completes).
94
+ function waitForInitialBuild(distDir, timeoutMs = 30_000) {
95
+ return new Promise((res, rej) => {
96
+ const contentJs = resolve(distDir, "content.js");
97
+ const start = Date.now();
98
+ const check = () => {
99
+ if (existsSync(contentJs)) return res();
100
+ if (Date.now() - start > timeoutMs) return rej(new Error("vite initial build timed out"));
101
+ setTimeout(check, 300);
102
+ };
103
+ check();
104
+ });
105
+ }
106
+
22
107
  export async function runDevUi(args = []) {
23
108
  const portIdx = args.indexOf("--port");
24
109
  const port = portIdx !== -1 ? args[portIdx + 1] : "4180";
25
110
 
26
111
  await checkKitUpdate();
27
- const { distDir } = await buildPlugin();
112
+ const pluginRoot = findPluginRoot();
113
+ const outDir = stageDevAssets(pluginRoot);
114
+
115
+ const node = findNodeBin();
116
+ const viteProc = spawnViteWatch(pluginRoot, node);
117
+
118
+ if (viteProc) {
119
+ log.step("waiting for initial vite build…");
120
+ await waitForInitialBuild(outDir);
121
+ } else {
122
+ log.warn("vite not found — UI will not be bundled. Run `npm install` in the plugin project.");
123
+ }
28
124
 
29
125
  const previewRoot = findPreviewHostRoot();
30
126
  const serveEntry = resolve(previewRoot, "serve.mjs");
31
127
  if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
32
128
  log.step("building preview-host bundle (first run)…");
33
129
  await new Promise((res, rej) => {
34
- const p = spawn("node", [resolve(previewRoot, "build.mjs")], {
130
+ const p = spawn(node ?? process.execPath, [resolve(previewRoot, "build.mjs")], {
35
131
  stdio: "inherit",
36
132
  });
37
133
  p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
@@ -40,11 +136,18 @@ export async function runDevUi(args = []) {
40
136
 
41
137
  log.step(`starting preview-host on :${port}`);
42
138
  const child = spawn(
43
- "node",
44
- [serveEntry, "--plugin", distDir, "--port", String(port)],
139
+ node ?? process.execPath,
140
+ [serveEntry, "--plugin", outDir, "--port", String(port)],
45
141
  { stdio: "inherit" },
46
142
  );
143
+
144
+ const cleanup = () => {
145
+ if (viteProc && !viteProc.killed) viteProc.kill();
146
+ if (!child.killed) child.kill();
147
+ };
148
+ process.once("SIGINT", cleanup);
149
+ process.once("SIGTERM", cleanup);
47
150
  child.on("exit", (code) => process.exit(code ?? 0));
48
151
  }
49
152
 
50
- export { findPluginRoot };
153
+ export { findPluginRoot, findPreviewHostRoot };
@@ -1,15 +1,56 @@
1
+ // When compiled with `bun --compile`, esbuild's native subprocess binary is
2
+ // not bundled. Use Bun's built-in bundler instead.
3
+ async function buildWithBun(options) {
4
+ const { entryPoints, outfile, format, minify, loader, define, external } = options;
5
+ const { dirname } = await import("node:path");
6
+ const result = await Bun.build({
7
+ entrypoints: entryPoints,
8
+ outdir: dirname(outfile),
9
+ format: format ?? "esm",
10
+ minify: minify ?? false,
11
+ loader,
12
+ define,
13
+ external,
14
+ target: "node",
15
+ throw: false,
16
+ });
17
+ if (!result.success) {
18
+ for (const log of result.logs) console.error(log.message);
19
+ throw new Error("Bun.build() failed");
20
+ }
21
+ }
22
+
23
+ async function buildWithWasm(options) {
24
+ const wasm = await import("esbuild-wasm");
25
+ return wasm.build(options);
26
+ }
27
+
28
+ function isNativeMissing(err) {
29
+ const msg = String(err?.message || err?.code || err || "");
30
+ return (
31
+ msg.includes("another platform") ||
32
+ msg.includes("Cannot find module") ||
33
+ msg.includes("Cannot find package") ||
34
+ msg.includes("not found") ||
35
+ msg.includes("No such file") ||
36
+ msg.includes("ENOENT") ||
37
+ msg.includes("MODULE_NOT_FOUND")
38
+ );
39
+ }
40
+
1
41
  export async function buildWithFallback(options) {
2
- const native = await import("esbuild");
42
+ if (typeof Bun !== "undefined") return buildWithBun(options);
43
+
44
+ // Try native esbuild (faster, no extra setup). Falls back to wasm when:
45
+ // - esbuild was not installed (removed from hard dependencies to avoid
46
+ // postinstall script blocking npm i -g on Windows)
47
+ // - binary was built for a different platform
3
48
  try {
49
+ const native = await import("esbuild");
4
50
  return await native.build(options);
5
- } catch (error) {
6
- const message = String(error?.message || error);
7
- if (!message.includes("another platform")) {
8
- throw error;
9
- }
10
-
11
- const wasm = await import("esbuild-wasm");
12
- console.warn("[muscat-pk] native esbuild binary mismatch; falling back to esbuild-wasm");
13
- return wasm.build(options);
51
+ } catch (err) {
52
+ if (!isNativeMissing(err)) throw err;
14
53
  }
54
+
55
+ return buildWithWasm(options);
15
56
  }