@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.3 → 0.1.2-dev.30
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/README.md +95 -16
- package/cli/README.md +4 -5
- package/cli/bin/create-muscat-plugin.mjs +1 -1
- package/cli/bin/muscat-pk.mjs +16 -10
- package/cli/docker/Dockerfile.bridge +17 -0
- package/cli/docker/Dockerfile.cpp +22 -0
- package/cli/docker/Dockerfile.dev +14 -0
- package/cli/docker/Dockerfile.python +20 -0
- package/cli/docker/bridge-package.json +8 -0
- package/cli/src/bridge.mjs +92 -0
- package/cli/src/build.mjs +406 -36
- package/cli/src/check-package.mjs +2 -1
- package/cli/src/cpp.mjs +65 -0
- package/cli/src/dev.mjs +16 -3
- package/cli/src/devBack.mjs +246 -49
- package/cli/src/devUi.mjs +118 -15
- package/cli/src/esbuild.mjs +51 -10
- package/cli/src/manifest.mjs +189 -0
- package/cli/src/new.mjs +152 -27
- package/cli/src/preview.mjs +91 -0
- package/cli/src/python.mjs +97 -0
- package/cli/src/ros2.mjs +66 -0
- package/cli/src/socketBridge.mjs +129 -0
- package/cli/src/type.mjs +114 -0
- package/cli/src/update.mjs +267 -30
- package/cli/src/util.mjs +65 -4
- package/cli/templates/common/README.md +382 -9
- package/cli/templates/common/manifest.json +13 -13
- package/cli/templates/common/package.json +5 -11
- package/cli/templates/common/py/main.py +88 -0
- package/cli/templates/common/requirements.txt +4 -0
- package/cli/templates/common/tsconfig.json +1 -1
- package/cli/templates/common/{src → ui}/content.tsx +3 -2
- package/cli/templates/common/vite.config.ts +22 -13
- package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
- package/cli/templates/cpp/cpp/main.cpp +25 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
- package/package.json +4 -4
- package/cli/src/pack.mjs +0 -58
- package/cli/templates/sdk/main.py +0 -21
- package/cli/templates/sdk/requirements.txt +0 -1
package/cli/src/ros2.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared ROS2 Docker helpers (Jazzy + Python 3.12 — matches the robot backend).
|
|
2
|
+
// Used by dev:back (interactive dev loop) and build (export install/ into the ZIP).
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
|
|
9
|
+
|
|
10
|
+
// Dockerfile lives inside the kit — plugin authors never see or edit it.
|
|
11
|
+
// It has no COPY/ADD, so the build context is irrelevant (its own dir is used).
|
|
12
|
+
const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.dev");
|
|
13
|
+
|
|
14
|
+
/** First package directory under ros2_ws/src (the colcon package name). */
|
|
15
|
+
export function ros2PackageName(root) {
|
|
16
|
+
const srcDir = resolve(root, "ros2_ws/src");
|
|
17
|
+
if (!existsSync(srcDir)) return null;
|
|
18
|
+
const dirs = readdirSync(srcDir, { withFileTypes: true })
|
|
19
|
+
.filter((d) => d.isDirectory())
|
|
20
|
+
.map((d) => d.name);
|
|
21
|
+
return dirs[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertDockerReady() {
|
|
25
|
+
if (!hasBin("docker")) {
|
|
26
|
+
throw new Error("Docker not found. ROS2 needs Docker (Jazzy + Python 3.12 container).");
|
|
27
|
+
}
|
|
28
|
+
if (!existsSync(DOCKERFILE)) {
|
|
29
|
+
throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
|
|
34
|
+
export function ensureImage(image = ROS2_DEV_IMAGE) {
|
|
35
|
+
log.step(`docker build -t ${image} (first run is slow, then cached)…`);
|
|
36
|
+
const r = spawnSync(
|
|
37
|
+
"docker",
|
|
38
|
+
["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
|
|
39
|
+
{ stdio: "inherit" },
|
|
40
|
+
);
|
|
41
|
+
if (r.status !== 0) throw new Error("docker build failed.");
|
|
42
|
+
pruneMuskatImages();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `colcon build` inside the container, producing ros2_ws/install on the host
|
|
47
|
+
* (the workspace is mounted). symlink=true for dev (fast, edit-in-place);
|
|
48
|
+
* symlink=false for export builds that ship in the ZIP (real copies, relocatable).
|
|
49
|
+
*/
|
|
50
|
+
export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false } = {}) {
|
|
51
|
+
const flag = symlink ? "--symlink-install" : "";
|
|
52
|
+
const script = [
|
|
53
|
+
// --ignore-installed: don't try to uninstall debian-managed packages
|
|
54
|
+
// (e.g. psutil), which fails with "RECORD file not found".
|
|
55
|
+
"pip install -r requirements.txt --break-system-packages --ignore-installed -q || true",
|
|
56
|
+
"cd ros2_ws",
|
|
57
|
+
`colcon build ${flag}`.trim(),
|
|
58
|
+
].join(" && ");
|
|
59
|
+
log.step(`colcon build${symlink ? " --symlink-install" : ""} (in container)…`);
|
|
60
|
+
const r = spawnSync(
|
|
61
|
+
"docker",
|
|
62
|
+
["run", "--rm", "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
|
|
63
|
+
{ cwd: root, stdio: "inherit" },
|
|
64
|
+
);
|
|
65
|
+
if (r.status !== 0) throw new Error("colcon build failed.");
|
|
66
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Server } from "socket.io";
|
|
5
|
+
|
|
6
|
+
// Self-contained logger — this module also runs standalone inside the bridge
|
|
7
|
+
// Docker container, where ./util.mjs is not available.
|
|
8
|
+
const log = {
|
|
9
|
+
step: (...m) => console.log("›", ...m),
|
|
10
|
+
warn: (...m) => console.warn("⚠", ...m),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
16
|
+
const httpServer = createServer();
|
|
17
|
+
const io = new Server(httpServer, { cors: { origin: "*" } });
|
|
18
|
+
|
|
19
|
+
// topic → socketId (serve_register로 등록된 queryable)
|
|
20
|
+
const queryables = new Map();
|
|
21
|
+
// call_id → { timer, resolve } (action → serve_call → serve_reply 대기)
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
|
|
24
|
+
function cleanupSocket(socket) {
|
|
25
|
+
for (const [topic, sid] of queryables) {
|
|
26
|
+
if (sid === socket.id) queryables.delete(topic);
|
|
27
|
+
}
|
|
28
|
+
for (const [id, p] of pending) {
|
|
29
|
+
if (p.sid !== socket.id) continue;
|
|
30
|
+
clearTimeout(p.timer);
|
|
31
|
+
p.resolve(undefined);
|
|
32
|
+
pending.delete(id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
io.on("connection", (socket) => {
|
|
37
|
+
socket.on("sub", async (data = {}) => {
|
|
38
|
+
if (!data.topic) return;
|
|
39
|
+
await socket.join(`t:${data.topic}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
socket.on("unsub", async (data = {}) => {
|
|
43
|
+
if (!data.topic) return;
|
|
44
|
+
await socket.leave(`t:${data.topic}`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
socket.on("pub", (data = {}) => {
|
|
48
|
+
const { topic, payload } = data;
|
|
49
|
+
if (!topic) return;
|
|
50
|
+
io.to(`t:${topic}`).emit("msg", { topic, payload });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
socket.on("serve_register", (data = {}) => {
|
|
54
|
+
if (!data.topic) return;
|
|
55
|
+
queryables.set(data.topic, socket.id);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
socket.on("serve_unregister", (data = {}) => {
|
|
59
|
+
if (queryables.get(data.topic) === socket.id) queryables.delete(data.topic);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
socket.on("serve_reply", (data = {}) => {
|
|
63
|
+
const p = pending.get(data.id);
|
|
64
|
+
if (!p) return;
|
|
65
|
+
clearTimeout(p.timer);
|
|
66
|
+
p.resolve(data.payload);
|
|
67
|
+
pending.delete(data.id);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
socket.on("action", (data = {}, ack) => {
|
|
71
|
+
const { topic, payload } = data;
|
|
72
|
+
const targetSid = queryables.get(topic);
|
|
73
|
+
const target = targetSid && io.sockets.sockets.get(targetSid);
|
|
74
|
+
if (!target) {
|
|
75
|
+
if (typeof ack === "function") ack(undefined);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const id = randomUUID();
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
pending.delete(id);
|
|
81
|
+
if (typeof ack === "function") ack(undefined);
|
|
82
|
+
}, DEFAULT_ACTION_TIMEOUT_MS);
|
|
83
|
+
pending.set(id, {
|
|
84
|
+
sid: targetSid,
|
|
85
|
+
timer,
|
|
86
|
+
resolve: (v) => { if (typeof ack === "function") ack(v); },
|
|
87
|
+
});
|
|
88
|
+
target.emit("serve_call", { id, topic, payload });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
socket.on("disconnect", () => cleanupSocket(socket));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
httpServer.once("error", (err) => {
|
|
96
|
+
if (err.code === "EADDRINUSE") {
|
|
97
|
+
log.warn(`Socket.IO bridge already running on :${port} — skipping`);
|
|
98
|
+
io.close();
|
|
99
|
+
httpServer.close();
|
|
100
|
+
resolve({ close: () => {} });
|
|
101
|
+
} else {
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
httpServer.listen(port, host, () => {
|
|
107
|
+
log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
|
|
108
|
+
|
|
109
|
+
const close = () => {
|
|
110
|
+
for (const p of pending.values()) {
|
|
111
|
+
clearTimeout(p.timer);
|
|
112
|
+
p.resolve(undefined);
|
|
113
|
+
}
|
|
114
|
+
pending.clear();
|
|
115
|
+
queryables.clear();
|
|
116
|
+
io.close();
|
|
117
|
+
httpServer.close();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
resolve({ close });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// When executed directly (inside the bridge Docker container), start the server.
|
|
126
|
+
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
127
|
+
const port = Number(process.env.PORT) || 10000;
|
|
128
|
+
startSocketBridge({ port });
|
|
129
|
+
}
|
package/cli/src/type.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// muscat-pk type change — switch an existing plugin's backend type.
|
|
2
|
+
// python | cpp | ros2 (rclpy) | ros2-cpp (rclcpp).
|
|
3
|
+
// Adds the matching backend overlay; never deletes existing user code
|
|
4
|
+
// (e.g. ros2_ws/ or cpp/ are left in place when switching away).
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { basename, resolve } from "node:path";
|
|
8
|
+
import { findPluginRoot, log } from "./util.mjs";
|
|
9
|
+
import {
|
|
10
|
+
TYPE_CHOICES,
|
|
11
|
+
applyTokens,
|
|
12
|
+
deriveNames,
|
|
13
|
+
overlayCpp,
|
|
14
|
+
overlayRos2,
|
|
15
|
+
runtimeForChoice,
|
|
16
|
+
} from "./new.mjs";
|
|
17
|
+
|
|
18
|
+
const TYPES = Object.keys(TYPE_CHOICES);
|
|
19
|
+
|
|
20
|
+
async function prompt(question, fallback) {
|
|
21
|
+
if (!process.stdin.isTTY) return fallback;
|
|
22
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
23
|
+
const answer = (await rl.question(question)).trim();
|
|
24
|
+
rl.close();
|
|
25
|
+
return answer || fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readManifest(root) {
|
|
29
|
+
const path = resolve(root, "manifest.json");
|
|
30
|
+
if (!existsSync(path)) throw new Error(`manifest.json not found in ${root}`);
|
|
31
|
+
return { path, json: JSON.parse(readFileSync(path, "utf8")) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Derive the current `--type`-style key from a manifest runtime block.
|
|
35
|
+
function currentTypeKey(runtime) {
|
|
36
|
+
if (runtime?.type === "ros2") return runtime.rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
|
|
37
|
+
return runtime?.type ?? "python";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runType(args) {
|
|
41
|
+
// accept `type change`, `type`, `type --to ros2-cpp`
|
|
42
|
+
const sub = args[0];
|
|
43
|
+
const toFlagIdx = args.indexOf("--to");
|
|
44
|
+
let to = toFlagIdx >= 0 ? args[toFlagIdx + 1] : undefined;
|
|
45
|
+
|
|
46
|
+
const root = findPluginRoot();
|
|
47
|
+
const { path, json } = readManifest(root);
|
|
48
|
+
const current = currentTypeKey(json.runtime);
|
|
49
|
+
|
|
50
|
+
if (sub && sub !== "change") {
|
|
51
|
+
log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|cpp|ros2|ros2-cpp]`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!to) {
|
|
55
|
+
console.log(`\n현재 타입: ${current}`);
|
|
56
|
+
console.log("변경할 타입을 선택하세요:");
|
|
57
|
+
console.log(" 1) Python");
|
|
58
|
+
console.log(" 2) C++");
|
|
59
|
+
console.log(" 3) ROS2 (rclpy / Python)");
|
|
60
|
+
console.log(" 4) ROS2 (rclcpp / C++)");
|
|
61
|
+
const choice = await prompt("\n선택 [1/2/3/4]: ", "");
|
|
62
|
+
to = { "1": "python", "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!TYPES.includes(to)) {
|
|
66
|
+
throw new Error(`invalid type. choose python | cpp | ros2 | ros2-cpp.`);
|
|
67
|
+
}
|
|
68
|
+
if (to === current) {
|
|
69
|
+
log.info(`already '${current}' — nothing to change.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const runtimeChoice = TYPE_CHOICES[to];
|
|
74
|
+
const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
|
|
75
|
+
|
|
76
|
+
if (runtimeChoice.type === "ros2") {
|
|
77
|
+
if (existsSync(resolve(root, "ros2_ws"))) {
|
|
78
|
+
log.warn("ros2_ws/ already exists — keeping it, only flipping manifest type.");
|
|
79
|
+
} else {
|
|
80
|
+
log.step(`adding ros2_ws/ overlay (${runtimeChoice.rosClient})`);
|
|
81
|
+
overlayRos2(root, slug, runtimeChoice.rosClient);
|
|
82
|
+
applyTokens(root, {
|
|
83
|
+
__PLUGIN_ID__: pluginId,
|
|
84
|
+
__PLUGIN_NAME__: pluginName,
|
|
85
|
+
__PLUGIN_SLUG__: slug,
|
|
86
|
+
__PLUGIN_CLASS__: pluginClass,
|
|
87
|
+
__RUNTIME_TYPE__: "ros2",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} else if (runtimeChoice.type === "cpp") {
|
|
91
|
+
if (existsSync(resolve(root, "cpp"))) {
|
|
92
|
+
log.warn("cpp/ already exists — keeping it, only flipping manifest type.");
|
|
93
|
+
} else {
|
|
94
|
+
log.step("adding cpp/ overlay");
|
|
95
|
+
overlayCpp(root);
|
|
96
|
+
applyTokens(root, {
|
|
97
|
+
__PLUGIN_ID__: pluginId,
|
|
98
|
+
__PLUGIN_NAME__: pluginName,
|
|
99
|
+
__PLUGIN_SLUG__: slug,
|
|
100
|
+
__PLUGIN_CLASS__: pluginClass,
|
|
101
|
+
__RUNTIME_TYPE__: "cpp",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// → python. Leave any ros2_ws/ or cpp/ in place (never delete user code).
|
|
106
|
+
if (existsSync(resolve(root, "ros2_ws")) || existsSync(resolve(root, "cpp"))) {
|
|
107
|
+
log.warn("existing backend dir left in place (not deleted). Remove it manually if unused.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
json.runtime = runtimeForChoice(runtimeChoice, slug);
|
|
112
|
+
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
|
113
|
+
log.ok(`${basename(root)} type: ${current} → ${to}`);
|
|
114
|
+
}
|
package/cli/src/update.mjs
CHANGED
|
@@ -1,52 +1,289 @@
|
|
|
1
|
-
// muscat-pk update — update the
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// muscat-pk update — update the CLI itself AND the plugin project's JS deps.
|
|
2
|
+
//
|
|
3
|
+
// Three install modes are handled:
|
|
4
|
+
// binary : Bun self-contained binary downloaded from S3
|
|
5
|
+
// → re-download from S3, replace process.execPath
|
|
6
|
+
// npm -g : globally installed Node.js package
|
|
7
|
+
// → npm install -g @rainbow-robotics/muscat-plugin-kit@<tag>
|
|
8
|
+
// local : plugin project's node_modules (fallback)
|
|
9
|
+
// → <pm> add @rainbow-robotics/muscat-plugin-kit@<tag>
|
|
10
|
+
//
|
|
11
|
+
// After updating the CLI itself, also bumps the plugin project's local deps
|
|
12
|
+
// and migrates the project structure if it's outdated (src/ → ui/, root main.py → py/).
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { execSync, spawn } from "node:child_process";
|
|
15
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { findPluginRoot, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs } from "./util.mjs";
|
|
6
19
|
|
|
7
20
|
const PLUGIN_UI_PACKAGE = "@rainbow-robotics/plugin-ui";
|
|
21
|
+
const S3_BASE = "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit";
|
|
22
|
+
|
|
23
|
+
const isBinaryMode = typeof Bun !== "undefined";
|
|
24
|
+
|
|
25
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
8
26
|
|
|
9
27
|
function detectPm(root) {
|
|
10
28
|
try {
|
|
11
29
|
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
|
|
12
30
|
if (pkg.packageManager) return pkg.packageManager.split("@")[0];
|
|
13
|
-
} catch {
|
|
14
|
-
/* ignore */
|
|
15
|
-
}
|
|
31
|
+
} catch { /* ignore */ }
|
|
16
32
|
if (existsSync(resolve(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
17
33
|
if (existsSync(resolve(root, "yarn.lock"))) return "yarn";
|
|
18
34
|
if (existsSync(resolve(root, "bun.lockb"))) return "bun";
|
|
19
35
|
return "npm";
|
|
20
36
|
}
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
async function fetchText(url) {
|
|
39
|
+
const res = await fetch(url);
|
|
40
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
41
|
+
return (await res.text()).trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchBinary(url) {
|
|
45
|
+
const res = await fetch(url);
|
|
46
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
47
|
+
return Buffer.from(await res.arrayBuffer());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function s3BinaryName() {
|
|
51
|
+
const platform = process.platform;
|
|
52
|
+
const arch = process.arch;
|
|
53
|
+
const plat = platform === "win32" ? "win" : platform === "darwin" ? "darwin" : "linux";
|
|
54
|
+
const cpu = arch === "arm64" ? "arm64" : "x64";
|
|
55
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
56
|
+
return `muscat-pk-${plat}-${cpu}${ext}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function resolveS3Version(tag) {
|
|
60
|
+
const pointer = tag === "dev" ? "dev-version.txt" : "latest-version.txt";
|
|
61
|
+
return fetchText(`${S3_BASE}/${pointer}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── self-update: binary ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async function updateBinary(tag) {
|
|
67
|
+
const version = await resolveS3Version(tag);
|
|
68
|
+
const current = kitVersion();
|
|
69
|
+
if (version === current) {
|
|
70
|
+
log.ok(`Already up to date (v${current})`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const binaryName = s3BinaryName();
|
|
75
|
+
const url = `${S3_BASE}/v${version}/${binaryName}`;
|
|
76
|
+
log.step(`Downloading binary: v${current} → v${version}`);
|
|
26
77
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
opts.version ||
|
|
30
|
-
opts["muscat-plugin-kit"] ||
|
|
31
|
-
opts["plugin-ui"] ||
|
|
32
|
-
"latest";
|
|
33
|
-
const list = [
|
|
34
|
-
`${KIT_PUBLISHED_NAME}@${range}`,
|
|
35
|
-
`${PLUGIN_UI_PACKAGE}@${range}`,
|
|
36
|
-
];
|
|
78
|
+
const data = await fetchBinary(url);
|
|
79
|
+
const execPath = process.execPath;
|
|
37
80
|
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
// Windows: cannot overwrite a running .exe — write .new then schedule replacement
|
|
83
|
+
const newPath = execPath + ".new";
|
|
84
|
+
writeFileSync(newPath, data);
|
|
85
|
+
|
|
86
|
+
// PowerShell snippet that waits for this process to exit, then renames
|
|
87
|
+
const ps = `Start-Sleep -Milliseconds 500; Move-Item -Force '${newPath}' '${execPath}'`;
|
|
88
|
+
spawn("powershell", ["-NoProfile", "-Command", ps], {
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: "ignore",
|
|
91
|
+
}).unref();
|
|
92
|
+
|
|
93
|
+
log.ok(`v${version} will be applied on next run.`);
|
|
94
|
+
} else {
|
|
95
|
+
// macOS / Linux: can replace the file while it's mapped into memory
|
|
96
|
+
const tmp = join(tmpdir(), `muscat-pk-${version}`);
|
|
97
|
+
writeFileSync(tmp, data);
|
|
98
|
+
chmodSync(tmp, 0o755);
|
|
99
|
+
copyFileSync(tmp, execPath);
|
|
100
|
+
log.ok(`Updated to v${version}.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── self-update: npm global ───────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function updateNpmGlobal(tag) {
|
|
107
|
+
log.step(`npm install -g ${KIT_PUBLISHED_NAME}@${tag}`);
|
|
108
|
+
execSync(`npm install -g ${KIT_PUBLISHED_NAME}@${tag}`, { stdio: "inherit" });
|
|
109
|
+
log.ok("Global CLI updated.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── project structure migration ───────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function detectOldStructure(root) {
|
|
115
|
+
const issues = [];
|
|
116
|
+
|
|
117
|
+
// src/ exists (old UI dir) or vite.config references src/content.tsx
|
|
118
|
+
const hasSrcDir = existsSync(resolve(root, "src"));
|
|
119
|
+
const hasUiDir = existsSync(resolve(root, "ui"));
|
|
120
|
+
const viteRef = (() => {
|
|
121
|
+
try {
|
|
122
|
+
return readFileSync(resolve(root, "vite.config.ts"), "utf8").includes("src/content.tsx");
|
|
123
|
+
} catch { return false; }
|
|
124
|
+
})();
|
|
125
|
+
if ((hasSrcDir || viteRef) && !hasUiDir) {
|
|
126
|
+
issues.push({ kind: "src→ui", desc: "src/ → ui/ (+ vite.config.ts, tsconfig.json)" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (existsSync(resolve(root, "main.py")) && !existsSync(resolve(root, "py"))) {
|
|
130
|
+
issues.push({ kind: "main→py", desc: "main.py → py/main.py" });
|
|
131
|
+
}
|
|
132
|
+
if (existsSync(resolve(root, "backend"))) {
|
|
133
|
+
issues.push({ kind: "backend→py", desc: "backend/ → py/" });
|
|
134
|
+
}
|
|
135
|
+
return issues;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function prompt(question) {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
141
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function migrateStructure(root, issues) {
|
|
146
|
+
log.warn("This plugin project's structure is outdated:");
|
|
147
|
+
for (const issue of issues) log.info(" • " + issue.desc);
|
|
148
|
+
log.warn("Files will be moved and modified. Back up or commit your work before proceeding.");
|
|
149
|
+
|
|
150
|
+
const answer = await prompt("Migrate project structure? [y/N] ");
|
|
151
|
+
if (answer.toLowerCase() !== "y") {
|
|
152
|
+
log.info("Structure migration skipped.");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const issue of issues) {
|
|
157
|
+
if (issue.kind === "src→ui") {
|
|
158
|
+
if (existsSync(resolve(root, "src"))) {
|
|
159
|
+
renameSync(resolve(root, "src"), resolve(root, "ui"));
|
|
160
|
+
log.ok("src/ → ui/");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const vitePath = resolve(root, "vite.config.ts");
|
|
164
|
+
if (existsSync(vitePath)) {
|
|
165
|
+
writeFileSync(vitePath, readFileSync(vitePath, "utf8").replace(/["']src\/content\.tsx["']/g, '"ui/content.tsx"'));
|
|
166
|
+
log.ok("vite.config.ts entry updated");
|
|
167
|
+
}
|
|
168
|
+
const tscPath = resolve(root, "tsconfig.json");
|
|
169
|
+
if (existsSync(tscPath)) {
|
|
170
|
+
writeFileSync(tscPath, readFileSync(tscPath, "utf8").replace(/"src"/g, '"ui"'));
|
|
171
|
+
log.ok("tsconfig.json include updated");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (issue.kind === "main→py") {
|
|
176
|
+
mkdirSync(resolve(root, "py"), { recursive: true });
|
|
177
|
+
renameSync(resolve(root, "main.py"), resolve(root, "py", "main.py"));
|
|
178
|
+
log.ok("main.py → py/main.py");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (issue.kind === "backend→py") {
|
|
182
|
+
renameSync(resolve(root, "backend"), resolve(root, "py"));
|
|
183
|
+
log.ok("backend/ → py/");
|
|
184
|
+
const pyReqs = resolve(root, "py", "requirements.txt");
|
|
185
|
+
const rootReqs = resolve(root, "requirements.txt");
|
|
186
|
+
if (existsSync(pyReqs) && !existsSync(rootReqs)) {
|
|
187
|
+
renameSync(pyReqs, rootReqs);
|
|
188
|
+
log.ok("py/requirements.txt → requirements.txt (project root)");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
log.ok("Migration complete. Run `muscat-pk build` to verify.");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── plugin project local deps ─────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function updateLocalDeps(root, tag) {
|
|
199
|
+
const pm = detectPm(root);
|
|
200
|
+
const list = [`${KIT_PUBLISHED_NAME}@${tag}`, `${PLUGIN_UI_PACKAGE}@${tag}`];
|
|
38
201
|
const cmd =
|
|
39
|
-
pm === "npm"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
? `yarn add ${list.join(" ")}`
|
|
43
|
-
: `${pm} add ${list.join(" ")}`;
|
|
202
|
+
pm === "npm" ? `npm install ${list.join(" ")}`
|
|
203
|
+
: pm === "yarn" ? `yarn add ${list.join(" ")}`
|
|
204
|
+
: `${pm} add ${list.join(" ")}`;
|
|
44
205
|
|
|
45
|
-
log.step(
|
|
206
|
+
log.step(`Updating plugin local deps (${pm}): ${list.join(", ")}`);
|
|
46
207
|
try {
|
|
47
208
|
execSync(cmd, { cwd: root, stdio: "inherit" });
|
|
48
|
-
log.ok("
|
|
209
|
+
log.ok("Local deps updated.");
|
|
210
|
+
} catch {
|
|
211
|
+
log.warn("Local deps update failed (offline?). Run manually:\n " + cmd);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function resolveTag(explicitTag) {
|
|
218
|
+
if (explicitTag) return explicitTag;
|
|
219
|
+
|
|
220
|
+
const current = kitVersion();
|
|
221
|
+
const isDevVersion = current.includes("-dev.");
|
|
222
|
+
|
|
223
|
+
if (!isDevVersion) return "latest";
|
|
224
|
+
|
|
225
|
+
const answer = await prompt(
|
|
226
|
+
`You are on a dev version (v${current}).\n` +
|
|
227
|
+
` [1] Stay on dev — update to latest dev build\n` +
|
|
228
|
+
` [2] Switch to stable — update to latest stable release\n` +
|
|
229
|
+
`Choice [1/2, default: 1]: `
|
|
230
|
+
);
|
|
231
|
+
return answer.trim() === "2" ? "latest" : "dev";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function runUpdate(args = []) {
|
|
235
|
+
const opts = parseArgs(args);
|
|
236
|
+
const explicitTag = opts._[0] || opts.version || opts.tag || "";
|
|
237
|
+
const tag = await resolveTag(explicitTag);
|
|
238
|
+
|
|
239
|
+
// 1. Update the CLI itself
|
|
240
|
+
if (isBinaryMode) {
|
|
241
|
+
log.step("Updating binary…");
|
|
242
|
+
try {
|
|
243
|
+
await updateBinary(tag);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log.warn(`Binary update failed: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const isGlobal = (() => {
|
|
249
|
+
try {
|
|
250
|
+
const prefix = execSync("npm prefix -g", { encoding: "utf8" }).trim();
|
|
251
|
+
return (
|
|
252
|
+
// Unix: {prefix}/bin/muscat-pk
|
|
253
|
+
existsSync(join(prefix, "bin", "muscat-pk")) ||
|
|
254
|
+
// Windows npm: {prefix}/muscat-pk.cmd (no bin/ subdir)
|
|
255
|
+
existsSync(join(prefix, "muscat-pk.cmd")) ||
|
|
256
|
+
existsSync(join(prefix, "bin", "muscat-pk.cmd"))
|
|
257
|
+
);
|
|
258
|
+
} catch { return false; }
|
|
259
|
+
})();
|
|
260
|
+
|
|
261
|
+
if (isGlobal) {
|
|
262
|
+
try {
|
|
263
|
+
updateNpmGlobal(tag);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
log.warn(`Global CLI update failed: ${err.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Plugin project: structure migration + local deps
|
|
271
|
+
let root;
|
|
272
|
+
try {
|
|
273
|
+
root = findPluginRoot();
|
|
49
274
|
} catch {
|
|
50
|
-
|
|
275
|
+
// Not inside a plugin project — skip
|
|
276
|
+
return;
|
|
51
277
|
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const structureIssues = detectOldStructure(root);
|
|
281
|
+
if (structureIssues.length > 0) {
|
|
282
|
+
await migrateStructure(root, structureIssues);
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
log.warn(`Structure check failed: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
updateLocalDeps(root, tag);
|
|
52
289
|
}
|