cozyclay 1.3.0 → 1.6.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/CHANGELOG.md +130 -6
- package/LICENSE +67 -80
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSING.md +32 -0
- package/README.md +69 -12
- package/THIRD_PARTY_NOTICES.md +20 -2
- package/bin/cozyclay.mjs +230 -88
- package/bin/mcp-runtime.mjs +115 -0
- package/bin/open-browser.mjs +10 -0
- package/bin/package-signature.mjs +95 -0
- package/bin/telemetry-state.mjs +111 -0
- package/bin/update-check.mjs +158 -0
- package/dist/ai-camera-control/index.html +4 -5
- package/dist/app/index.html +3 -4
- package/dist/assets/app-CqOk49Bk.css +1 -0
- package/dist/assets/app-G55dPEDB.js +4811 -0
- package/dist/assets/module-MzlRiBjI.js +3 -0
- package/dist/cozyclay-package.json +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +27 -23
- package/dist/sw.js +2 -1
- package/mcp/LIVE-PROTOCOL.md +93 -12
- package/mcp/README.md +28 -10
- package/mcp/ardy-prompts.mjs +96 -78
- package/mcp/live-hub.mjs +231 -17
- package/mcp/package.json +5 -2
- package/mcp/runtime/package-lock.json +1211 -0
- package/mcp/runtime/package.json +16 -0
- package/mcp/server.mjs +798 -184
- package/package.json +32 -8
- package/src/App.jsx +2744 -713
- package/src/analytics-toggle.jsx +24 -0
- package/src/analytics.js +386 -0
- package/src/ardy/motion-edit.js +172 -0
- package/src/ardy/pose-pin.js +97 -0
- package/src/ardy/root-drop.js +180 -0
- package/src/ardy/timeline-resize.js +3 -1
- package/src/ardy/timeline.jsx +362 -73
- package/src/ardy/waypoints.js +4 -1
- package/src/asset-pane.jsx +339 -21
- package/src/asset-shelf.js +82 -0
- package/src/camera-block.js +42 -0
- package/src/camera-follow.js +149 -27
- package/src/camera-move.js +45 -19
- package/src/camera-rail-schedule.js +6 -0
- package/src/cuts.js +67 -54
- package/src/dualview.jsx +45 -5
- package/src/generation/generation-request.js +17 -0
- package/src/generation/use-generation.js +3 -14
- package/src/hierarchy-model.js +5 -3
- package/src/live-control.js +22 -2
- package/src/main.jsx +2 -0
- package/src/mp4-muxer.js +52 -0
- package/src/object-gizmo.jsx +211 -16
- package/src/offscreen-export.js +166 -0
- package/src/otio.js +217 -0
- package/src/planview.jsx +18 -10
- package/src/pose-extract/detector.js +7 -2
- package/src/pose-extract/image-frame.js +40 -0
- package/src/pose-extract/index.js +2 -1
- package/src/pose-extract/take.js +27 -0
- package/src/poses.js +19 -265
- package/src/posestudio.jsx +82 -8
- package/src/project-poses.js +9 -0
- package/src/project.js +86 -2
- package/src/pwa.js +6 -0
- package/src/room.jsx +77 -4
- package/src/sample-at.js +136 -0
- package/src/scene-asset-cache.js +137 -96
- package/src/scene-assets.js +94 -4
- package/src/scene-objects.js +82 -19
- package/src/scenes.js +34 -6
- package/src/shot-authoring.js +65 -28
- package/src/shot.js +53 -13
- package/src/source-offer.jsx +12 -0
- package/src/stable-items.js +83 -0
- package/src/styles.css +882 -115
- package/src/ui.jsx +32 -4
- package/src/usd-camera.js +145 -0
- package/tools/ardy/BRIDGE.md +8 -5
- package/tools/ardy/README.md +15 -0
- package/tools/ardy/artifacts.mjs +34 -0
- package/tools/ardy/bridge.mjs +32 -11
- package/tools/ardy/extract.mjs +16 -11
- package/tools/ardy/footage.mjs +15 -10
- package/tools/ardy/runners/local.mjs +4 -1
- package/tools/ardy/setup-text-encoder.py +14 -4
- package/tools/dev-full.mjs +63 -5
- package/tools/generation/bridge.mjs +14 -1
- package/tools/metrics/archive-traffic.mjs +1 -1
- package/tools/metrics-snapshot.mjs +98 -0
- package/tools/prepare-package.mjs +50 -0
- package/tools/process-supervisor.mjs +108 -2
- package/tools/run-tests.mjs +173 -0
- package/dist/assets/app-B3U5aut1.js +0 -4811
- package/dist/assets/app-BrRF0wso.css +0 -1
package/bin/cozyclay.mjs
CHANGED
|
@@ -8,24 +8,49 @@
|
|
|
8
8
|
* `dist/`, so this launcher only has to
|
|
9
9
|
*
|
|
10
10
|
* - serve those files over loopback,
|
|
11
|
-
* - forward /ardy to
|
|
11
|
+
* - forward /ardy to its dynamically selected sidecar port (the job Vite's dev proxy does),
|
|
12
12
|
* - keep the sidecar's lifetime tied to this process.
|
|
13
13
|
*
|
|
14
14
|
* It has no dependencies on purpose. A launcher that needs an install step
|
|
15
15
|
* before it can serve a prebuilt app is a launcher that will break.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
|
-
import {
|
|
18
|
+
import { startBridge, terminateOwned } from "../tools/process-supervisor.mjs";
|
|
19
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
19
20
|
import { createServer, request as httpRequest } from "node:http";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
import { extname, join, normalize, resolve } from "node:path";
|
|
22
23
|
import { createInterface } from "node:readline";
|
|
23
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { runMcp } from "./mcp-runtime.mjs";
|
|
26
|
+
import { openBrowser } from "./open-browser.mjs";
|
|
27
|
+
import { checkForUpdate, runUpdate } from "./update-check.mjs";
|
|
28
|
+
import { verifyPackageMarker } from "./package-signature.mjs";
|
|
29
|
+
import {
|
|
30
|
+
markTelemetryNoticeShown,
|
|
31
|
+
markTelemetryFirstLaunch,
|
|
32
|
+
effectiveTelemetryEnabled,
|
|
33
|
+
readTelemetryState,
|
|
34
|
+
setTelemetryEnabled,
|
|
35
|
+
takeRuntimeTelemetryConfig,
|
|
36
|
+
TELEMETRY_NOTICE_VERSION,
|
|
37
|
+
} from "./telemetry-state.mjs";
|
|
24
38
|
|
|
25
39
|
const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
40
|
+
let packageMetadata = {};
|
|
41
|
+
try {
|
|
42
|
+
packageMetadata = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
// An unreadable package is handled by the existing missing-build checks.
|
|
45
|
+
}
|
|
26
46
|
const DIST = join(PKG_ROOT, "dist");
|
|
27
47
|
const BRIDGE = join(PKG_ROOT, "tools", "ardy", "bridge.mjs");
|
|
28
|
-
const
|
|
48
|
+
const OFFICIAL_PACKAGE = (
|
|
49
|
+
packageMetadata.name === "cozyclay"
|
|
50
|
+
&& !existsSync(join(PKG_ROOT, ".git"))
|
|
51
|
+
&& verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
|
|
52
|
+
);
|
|
53
|
+
let bridgePort = 5181;
|
|
29
54
|
|
|
30
55
|
const TYPES = {
|
|
31
56
|
".html": "text/html; charset=utf-8",
|
|
@@ -45,7 +70,7 @@ const TYPES = {
|
|
|
45
70
|
};
|
|
46
71
|
|
|
47
72
|
function parseArgs(argv) {
|
|
48
|
-
const opts = { port: 5180, host: "127.0.0.1", ardy: true, open: true, star: true };
|
|
73
|
+
const opts = { port: 5180, host: "127.0.0.1", ardy: true, open: true, star: true, updateCheck: true };
|
|
49
74
|
for (let i = 0; i < argv.length; i += 1) {
|
|
50
75
|
const arg = argv[i];
|
|
51
76
|
if (arg === "--port" || arg === "-p") opts.port = Number(argv[++i]);
|
|
@@ -55,21 +80,29 @@ function parseArgs(argv) {
|
|
|
55
80
|
else if (arg === "--no-ardy") opts.ardy = false;
|
|
56
81
|
else if (arg === "--no-open") opts.open = false;
|
|
57
82
|
else if (arg === "--no-star") opts.star = false;
|
|
83
|
+
else if (arg === "--no-update-check") opts.updateCheck = false;
|
|
58
84
|
else if (arg === "--help" || arg === "-h") opts.help = true;
|
|
59
85
|
else if (arg === "--version" || arg === "-v") opts.version = true;
|
|
60
86
|
else {
|
|
61
87
|
console.error(`cozyclay: unknown option ${arg}`);
|
|
62
88
|
opts.help = true;
|
|
89
|
+
opts.invalid = true;
|
|
63
90
|
}
|
|
64
91
|
}
|
|
65
|
-
if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port >
|
|
66
|
-
console.error("cozyclay: --port must be
|
|
92
|
+
if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65534) {
|
|
93
|
+
console.error("cozyclay: --port must be an integer in 1..65534");
|
|
94
|
+
opts.help = true;
|
|
95
|
+
opts.invalid = true;
|
|
96
|
+
}
|
|
97
|
+
if (opts.host !== "127.0.0.1") {
|
|
98
|
+
console.error("cozyclay: --host is restricted to 127.0.0.1");
|
|
67
99
|
opts.help = true;
|
|
100
|
+
opts.invalid = true;
|
|
68
101
|
}
|
|
69
102
|
return opts;
|
|
70
103
|
}
|
|
71
104
|
|
|
72
|
-
const REPO_URL = "https://github.com/
|
|
105
|
+
const REPO_URL = "https://github.com/NomaDamas/CozyClay";
|
|
73
106
|
const STATE_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cozyclay");
|
|
74
107
|
const STATE_FILE = join(STATE_DIR, "state.json");
|
|
75
108
|
// Only worth asking someone who actually used the thing. A prompt three
|
|
@@ -80,10 +113,18 @@ const HELP = `cozyclay - browser-based 3D staging studio
|
|
|
80
113
|
|
|
81
114
|
npx cozyclay start the studio and open it
|
|
82
115
|
npx cozyclay mcp run the MCP server (for Claude, Cursor, any MCP client)
|
|
116
|
+
cclay update install the latest cozyclay globally (npm install -g)
|
|
83
117
|
npx cozyclay --port 5200 serve on another port
|
|
84
118
|
npx cozyclay --no-ardy skip the optional motion-generation sidecar
|
|
85
119
|
npx cozyclay --no-open do not open a browser
|
|
86
120
|
npx cozyclay --no-star never ask about starring the repo
|
|
121
|
+
npx cozyclay --no-update-check
|
|
122
|
+
do not look for a newer release
|
|
123
|
+
cclay telemetry status show anonymous telemetry status
|
|
124
|
+
cclay telemetry off disable anonymous telemetry
|
|
125
|
+
cclay telemetry on enable anonymous telemetry
|
|
126
|
+
|
|
127
|
+
cclay is the same command, shorter: a global install gives you both.
|
|
87
128
|
|
|
88
129
|
Motion generation needs an SSH-reachable NVIDIA machine running ARDY; point
|
|
89
130
|
the sidecar at it with CCLAY_ARDY_HOST. Everything else - staging, posing,
|
|
@@ -105,7 +146,7 @@ function serveFile(res, path) {
|
|
|
105
146
|
// browser code needs no build-time knowledge of how it was launched.
|
|
106
147
|
function proxyToBridge(req, res) {
|
|
107
148
|
const upstream = httpRequest(
|
|
108
|
-
{ host: "127.0.0.1", port:
|
|
149
|
+
{ host: "127.0.0.1", port: bridgePort, path: req.url, method: req.method, headers: req.headers },
|
|
109
150
|
(upstreamRes) => {
|
|
110
151
|
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
111
152
|
upstreamRes.pipe(res);
|
|
@@ -131,7 +172,9 @@ function readState() {
|
|
|
131
172
|
function writeState(patch) {
|
|
132
173
|
try {
|
|
133
174
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
134
|
-
|
|
175
|
+
const temporary = `${STATE_FILE}.${process.pid}.tmp`;
|
|
176
|
+
writeFileSync(temporary, JSON.stringify({ ...readState(), ...patch }, null, "\t"), { mode: 0o600 });
|
|
177
|
+
renameSync(temporary, STATE_FILE);
|
|
135
178
|
} catch {
|
|
136
179
|
/* a read-only home is not a reason to fail a local dev server */
|
|
137
180
|
}
|
|
@@ -139,7 +182,7 @@ function writeState(patch) {
|
|
|
139
182
|
|
|
140
183
|
async function starCount() {
|
|
141
184
|
try {
|
|
142
|
-
const res = await fetch("https://api.github.com/repos/
|
|
185
|
+
const res = await fetch("https://api.github.com/repos/NomaDamas/CozyClay", {
|
|
143
186
|
headers: { accept: "application/vnd.github+json" },
|
|
144
187
|
signal: AbortSignal.timeout(2500),
|
|
145
188
|
});
|
|
@@ -180,69 +223,70 @@ async function maybeAskForStar(startedAt, opts) {
|
|
|
180
223
|
}
|
|
181
224
|
}
|
|
182
225
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
child.on("error", () => {
|
|
188
|
-
/* headless box, no browser: the URL is printed anyway */
|
|
189
|
-
});
|
|
190
|
-
child.unref();
|
|
226
|
+
// Read on every launch now, so it must not be able to take the launcher down:
|
|
227
|
+
// the version is cosmetic here, and "0.0.0" just suppresses a bogus notice.
|
|
228
|
+
function readVersion() {
|
|
229
|
+
return typeof packageMetadata.version === "string" ? packageMetadata.version : "0.0.0";
|
|
191
230
|
}
|
|
192
231
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
*/
|
|
202
|
-
async function runMcp(rest) {
|
|
203
|
-
const server = join(PKG_ROOT, "mcp", "server.mjs");
|
|
204
|
-
if (!existsSync(server)) {
|
|
205
|
-
console.error("cozyclay: this build does not include the MCP server.");
|
|
232
|
+
const argv = process.argv.slice(2);
|
|
233
|
+
if (argv[0] === "mcp") {
|
|
234
|
+
await runMcp(argv.slice(1));
|
|
235
|
+
} else if (argv[0] === "update") {
|
|
236
|
+
// This branch bypasses parseArgs, so anything trailing would be swallowed
|
|
237
|
+
// and `cclay update --help` would perform an unrequested global install.
|
|
238
|
+
if (argv.length > 1) {
|
|
239
|
+
console.error(`cozyclay: update takes no arguments (got ${argv.slice(1).join(" ")})`);
|
|
206
240
|
process.exit(1);
|
|
207
241
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
} catch {
|
|
214
|
-
console.error(
|
|
215
|
-
[
|
|
216
|
-
"cozyclay: the MCP server needs four packages the studio does not.",
|
|
217
|
-
"",
|
|
218
|
-
` npm install --prefix ${JSON.stringify(PKG_ROOT)} \\`,
|
|
219
|
-
" @modelcontextprotocol/sdk ws zod three",
|
|
220
|
-
"",
|
|
221
|
-
"Then run `npx cozyclay mcp` again. They are kept out of the default",
|
|
222
|
-
"install so opening the studio never waits on a 95-package tree.",
|
|
223
|
-
].join("\n"),
|
|
224
|
-
);
|
|
242
|
+
runUpdate();
|
|
243
|
+
} else if (argv[0] === "telemetry") {
|
|
244
|
+
const action = argv[1] ?? "status";
|
|
245
|
+
if (argv.length > 2 || !["status", "on", "off"].includes(action)) {
|
|
246
|
+
console.error("cozyclay: telemetry accepts status, on, or off");
|
|
225
247
|
process.exit(1);
|
|
226
248
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
249
|
+
if (action === "on") setTelemetryEnabled(STATE_FILE, true);
|
|
250
|
+
if (action === "off") setTelemetryEnabled(STATE_FILE, false);
|
|
251
|
+
const state = readTelemetryState(STATE_FILE);
|
|
252
|
+
const effective = OFFICIAL_PACKAGE && effectiveTelemetryEnabled(state);
|
|
253
|
+
const reason = !OFFICIAL_PACKAGE
|
|
254
|
+
? " (source checkout)"
|
|
255
|
+
: state.telemetryEnabled && !effective
|
|
256
|
+
? " (environment override)"
|
|
257
|
+
: "";
|
|
258
|
+
console.log(`Telemetry: ${effective ? "on" : "off"}${reason}`);
|
|
259
|
+
if (action !== "status") console.log("Reload any open CozyClay studio tab to apply this setting.");
|
|
234
260
|
} else {
|
|
235
261
|
|
|
236
262
|
const opts = parseArgs(argv);
|
|
237
|
-
if (opts.help) {
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
if (opts.help) {
|
|
264
|
+
console.log(HELP);
|
|
265
|
+
process.exit(opts.invalid ? 1 : 0);
|
|
240
266
|
}
|
|
267
|
+
const version = readVersion();
|
|
241
268
|
if (opts.version) {
|
|
242
|
-
|
|
243
|
-
console.log(pkg.version);
|
|
269
|
+
console.log(version);
|
|
244
270
|
process.exit(0);
|
|
245
271
|
}
|
|
272
|
+
let runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
|
|
273
|
+
appVersion: version,
|
|
274
|
+
officialPackage: OFFICIAL_PACKAGE,
|
|
275
|
+
});
|
|
276
|
+
const telemetryState = readTelemetryState(STATE_FILE);
|
|
277
|
+
if (
|
|
278
|
+
runtimeTelemetry.telemetryEnabled
|
|
279
|
+
&& telemetryState.noticeVersion < TELEMETRY_NOTICE_VERSION
|
|
280
|
+
) {
|
|
281
|
+
console.log(
|
|
282
|
+
"Anonymous usage metrics are on (no prompts, project content, filenames, or account information).\n" +
|
|
283
|
+
"Disable anytime: cclay telemetry off",
|
|
284
|
+
);
|
|
285
|
+
markTelemetryNoticeShown(STATE_FILE);
|
|
286
|
+
}
|
|
287
|
+
// Fired before anything blocking and never awaited on the launch path: the
|
|
288
|
+
// notice is worth a line of output, never a second of startup.
|
|
289
|
+
const updatePending = opts.updateCheck && !process.env.CI && !process.env.COZYCLAY_NO_UPDATE_CHECK ? checkForUpdate(version, STATE_DIR) : null;
|
|
246
290
|
if (!existsSync(join(DIST, "app", "index.html"))) {
|
|
247
291
|
console.error("cozyclay: this package is missing its build (dist/app/index.html).");
|
|
248
292
|
console.error("cozyclay: from a clone, run `npm install && npm run build` first.");
|
|
@@ -254,15 +298,98 @@ if (!existsSync(join(DIST, "app", "index.html"))) {
|
|
|
254
298
|
// cannot act on. An unset CCLAY_ARDY_HOST is the normal case, not a fault.
|
|
255
299
|
const ardyHost = process.env.CCLAY_ARDY_HOST?.trim();
|
|
256
300
|
let bridge = null;
|
|
301
|
+
let server = null;
|
|
302
|
+
const startedAt = Date.now();
|
|
303
|
+
let shuttingDown = false;
|
|
304
|
+
async function shutdown(exitCode = 0) {
|
|
305
|
+
if (shuttingDown) return;
|
|
306
|
+
shuttingDown = true;
|
|
307
|
+
if (bridge) await terminateOwned(bridge);
|
|
308
|
+
if (server?.listening) await new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
309
|
+
try {
|
|
310
|
+
await maybeAskForStar(startedAt, opts);
|
|
311
|
+
} catch {
|
|
312
|
+
/* never let the goodbye prompt hold the process hostage */
|
|
313
|
+
}
|
|
314
|
+
process.exit(exitCode);
|
|
315
|
+
}
|
|
316
|
+
process.on("SIGINT", () => shutdown(130));
|
|
317
|
+
process.on("SIGTERM", () => shutdown(143));
|
|
318
|
+
|
|
257
319
|
if (opts.ardy && ardyHost && existsSync(BRIDGE)) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
320
|
+
try {
|
|
321
|
+
({ child: bridge, port: bridgePort } = await startBridge({
|
|
322
|
+
command: process.execPath,
|
|
323
|
+
args: [BRIDGE],
|
|
324
|
+
cwd: PKG_ROOT,
|
|
325
|
+
env: process.env,
|
|
326
|
+
mainPort: opts.port,
|
|
327
|
+
onSpawn: (child) => {
|
|
328
|
+
bridge = child;
|
|
329
|
+
},
|
|
330
|
+
onReady: (child) => {
|
|
331
|
+
child.once("exit", (code, signal) => {
|
|
332
|
+
if (shuttingDown) return;
|
|
333
|
+
console.error(`cozyclay: motion generation sidecar exited ${signal ? `from ${signal}` : `with code ${code ?? 0}`}.`);
|
|
334
|
+
void shutdown(1);
|
|
335
|
+
});
|
|
336
|
+
child.once("error", (err) => {
|
|
337
|
+
if (shuttingDown) return;
|
|
338
|
+
console.error(`cozyclay: motion generation sidecar failed: ${err.message}`);
|
|
339
|
+
void shutdown(1);
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
}));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error(`cozyclay: motion generation sidecar failed: ${err.message}`);
|
|
345
|
+
console.error("cozyclay: studio did not start; set COZYCLAY_BRIDGE_PORT to an available port or use --no-ardy.");
|
|
346
|
+
if (bridge) await terminateOwned(bridge);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
262
349
|
}
|
|
263
350
|
|
|
264
|
-
|
|
351
|
+
server = createServer((req, res) => {
|
|
265
352
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
353
|
+
if (url.pathname === "/__cozyclay/telemetry") {
|
|
354
|
+
if (req.method === "POST") {
|
|
355
|
+
const origin = req.headers.origin;
|
|
356
|
+
const expectedOrigin = `http://127.0.0.1:${opts.port}`;
|
|
357
|
+
if (origin !== expectedOrigin) {
|
|
358
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
359
|
+
res.end(JSON.stringify({ error: "forbidden origin" }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
let body = "";
|
|
363
|
+
req.setEncoding("utf8");
|
|
364
|
+
req.on("data", (chunk) => {
|
|
365
|
+
body += chunk;
|
|
366
|
+
if (body.length > 1024) req.destroy();
|
|
367
|
+
});
|
|
368
|
+
req.on("end", () => {
|
|
369
|
+
try {
|
|
370
|
+
const value = JSON.parse(body);
|
|
371
|
+
if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
|
|
372
|
+
setTelemetryEnabled(STATE_FILE, value.enabled);
|
|
373
|
+
runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
|
|
374
|
+
appVersion: version,
|
|
375
|
+
officialPackage: OFFICIAL_PACKAGE,
|
|
376
|
+
});
|
|
377
|
+
res.writeHead(200, {
|
|
378
|
+
"content-type": "application/json; charset=utf-8",
|
|
379
|
+
"cache-control": "no-store",
|
|
380
|
+
});
|
|
381
|
+
res.end(JSON.stringify(runtimeTelemetry));
|
|
382
|
+
} catch {
|
|
383
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
384
|
+
res.end(JSON.stringify({ error: "invalid telemetry setting" }));
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
res.writeHead(405, { allow: "POST", "content-type": "application/json; charset=utf-8" });
|
|
390
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
266
393
|
// Only the routes the bridge actually owns: /ardy/ is ALSO a public asset
|
|
267
394
|
// directory (cskel27-rest.json), and those files live in dist/, not behind
|
|
268
395
|
// the sidecar. Same rule as the Vite dev proxy bypass.
|
|
@@ -270,7 +397,14 @@ const server = createServer((req, res) => {
|
|
|
270
397
|
proxyToBridge(req, res);
|
|
271
398
|
return;
|
|
272
399
|
}
|
|
273
|
-
|
|
400
|
+
let rel;
|
|
401
|
+
try {
|
|
402
|
+
rel = decodeURIComponent(url.pathname);
|
|
403
|
+
} catch {
|
|
404
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
405
|
+
res.end("invalid URL encoding");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
274
408
|
// normalize + prefix check: a request must not escape dist/.
|
|
275
409
|
let target = join(DIST, normalize(rel));
|
|
276
410
|
if (!target.startsWith(DIST)) {
|
|
@@ -289,41 +423,44 @@ const server = createServer((req, res) => {
|
|
|
289
423
|
res.end("not found");
|
|
290
424
|
return;
|
|
291
425
|
}
|
|
426
|
+
if (target === join(DIST, "app", "index.html")) {
|
|
427
|
+
runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
|
|
428
|
+
appVersion: version,
|
|
429
|
+
officialPackage: OFFICIAL_PACKAGE,
|
|
430
|
+
});
|
|
431
|
+
const runtime = runtimeTelemetry;
|
|
432
|
+
if (runtime.firstLaunch) {
|
|
433
|
+
markTelemetryFirstLaunch(STATE_FILE);
|
|
434
|
+
}
|
|
435
|
+
const script = `<script>window.__COZYCLAY_RUNTIME__ = ${JSON.stringify(runtime).replaceAll("<", "\\u003c")};</script>`;
|
|
436
|
+
const html = readFileSync(target, "utf8").replace("</head>", `${script}\n</head>`);
|
|
437
|
+
res.writeHead(200, {
|
|
438
|
+
"content-type": "text/html; charset=utf-8",
|
|
439
|
+
"content-length": Buffer.byteLength(html),
|
|
440
|
+
"cache-control": "no-store",
|
|
441
|
+
});
|
|
442
|
+
res.end(html);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
292
445
|
serveFile(res, target);
|
|
293
446
|
});
|
|
294
447
|
|
|
295
|
-
const startedAt = Date.now();
|
|
296
|
-
let shuttingDown = false;
|
|
297
|
-
async function shutdown() {
|
|
298
|
-
if (shuttingDown) process.exit(0);
|
|
299
|
-
shuttingDown = true;
|
|
300
|
-
if (bridge) bridge.kill("SIGTERM");
|
|
301
|
-
server.close();
|
|
302
|
-
try {
|
|
303
|
-
await maybeAskForStar(startedAt, opts);
|
|
304
|
-
} catch {
|
|
305
|
-
/* never let the goodbye prompt hold the process hostage */
|
|
306
|
-
}
|
|
307
|
-
process.exit(0);
|
|
308
|
-
}
|
|
309
|
-
process.on("SIGINT", shutdown);
|
|
310
|
-
process.on("SIGTERM", shutdown);
|
|
311
|
-
|
|
312
448
|
server.on("error", (err) => {
|
|
313
449
|
if (err && err.code === "EADDRINUSE") {
|
|
314
|
-
console.error(`cozyclay: port ${opts.port} is taken. Try --port
|
|
315
|
-
|
|
316
|
-
|
|
450
|
+
console.error(`cozyclay: port ${opts.port} is taken. Try --port 5200.`);
|
|
451
|
+
void shutdown(1);
|
|
452
|
+
return;
|
|
317
453
|
}
|
|
318
454
|
throw err;
|
|
319
455
|
});
|
|
320
456
|
|
|
321
|
-
server.listen(opts.port,
|
|
457
|
+
server.listen({ port: opts.port, host: "127.0.0.1", ipv6Only: false }, () => {
|
|
322
458
|
// The package exists to open the studio, which the site serves from /app/.
|
|
323
459
|
// Landing on "/" would greet someone who just typed `npx cozyclay` with a
|
|
324
460
|
// marketing page.
|
|
325
|
-
const url = `http
|
|
461
|
+
const url = `http://127.0.0.1:${opts.port}/app/`;
|
|
326
462
|
console.log(`CozyClay is running at ${url}`);
|
|
463
|
+
console.log("Use a Chromium-based browser — Safari and Firefox are not supported.");
|
|
327
464
|
if (!opts.ardy) console.log("Motion generation: off (--no-ardy).");
|
|
328
465
|
else if (bridge) console.log(`Motion generation: sidecar running against ${ardyHost}.`);
|
|
329
466
|
else
|
|
@@ -332,6 +469,11 @@ server.listen(opts.port, opts.host, () => {
|
|
|
332
469
|
"set CCLAY_ARDY_HOST=user@host to turn it on. Everything else works without it.",
|
|
333
470
|
);
|
|
334
471
|
if (opts.open) openBrowser(url);
|
|
472
|
+
void updatePending?.then((latest) => {
|
|
473
|
+
// `cclay` only exists after a global install, so name the command that
|
|
474
|
+
// works from an npx run too.
|
|
475
|
+
if (latest) console.log(`cozyclay ${latest} is available (you have ${version}). Run: npm install -g cozyclay@latest`);
|
|
476
|
+
});
|
|
335
477
|
});
|
|
336
478
|
|
|
337
479
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
const PACKAGE_VERSION = JSON.parse(await import("node:fs/promises").then((fs) => fs.readFile(join(PKG_ROOT, "package.json"), "utf8"))).version;
|
|
10
|
+
const MCP_RUNTIME_SOURCE = join(PKG_ROOT, "mcp", "runtime");
|
|
11
|
+
const MCP_RUNTIME = process.env.COZYCLAY_MCP_RUNTIME_DIR || join(
|
|
12
|
+
process.env.XDG_CACHE_HOME || process.env.LOCALAPPDATA || join(homedir(), ".cache"),
|
|
13
|
+
"cozyclay",
|
|
14
|
+
"mcp-runtime",
|
|
15
|
+
PACKAGE_VERSION,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function probeMcpRuntime(runtime = MCP_RUNTIME) {
|
|
19
|
+
const server = join(runtime, "mcp", "server.mjs");
|
|
20
|
+
if (!existsSync(server)) throw new Error("MCP server is missing");
|
|
21
|
+
const runtimeRequire = createRequire(join(runtime, "package.json"));
|
|
22
|
+
for (const dependency of ["@modelcontextprotocol/sdk/server/mcp.js", "three", "ws", "zod"]) runtimeRequire.resolve(dependency);
|
|
23
|
+
return server;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function installMcpRuntime() {
|
|
27
|
+
return new Promise((done) => {
|
|
28
|
+
const parent = dirname(MCP_RUNTIME);
|
|
29
|
+
mkdirSync(parent, { recursive: true });
|
|
30
|
+
try {
|
|
31
|
+
probeMcpRuntime();
|
|
32
|
+
done({ code: 0 });
|
|
33
|
+
return;
|
|
34
|
+
} catch {
|
|
35
|
+
// A killed older install can leave an incomplete final directory.
|
|
36
|
+
// Only complete stagings are ever renamed here, so an invalid final
|
|
37
|
+
// cache is safe to discard before this attempt begins.
|
|
38
|
+
rmSync(MCP_RUNTIME, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
const staging = mkdtempSync(join(parent, `${basename(MCP_RUNTIME)}.install-`));
|
|
41
|
+
copyFileSync(join(MCP_RUNTIME_SOURCE, "package.json"), join(staging, "package.json"));
|
|
42
|
+
copyFileSync(join(MCP_RUNTIME_SOURCE, "package-lock.json"), join(staging, "package-lock.json"));
|
|
43
|
+
cpSync(join(PKG_ROOT, "mcp"), join(staging, "mcp"), { recursive: true });
|
|
44
|
+
cpSync(join(PKG_ROOT, "src"), join(staging, "src"), { recursive: true });
|
|
45
|
+
|
|
46
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
47
|
+
const child = spawn(npm, ["ci", "--no-audit", "--no-fund"], {
|
|
48
|
+
cwd: staging,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
shell: process.platform === "win32",
|
|
51
|
+
});
|
|
52
|
+
child.stdout.pipe(process.stderr);
|
|
53
|
+
child.stderr.pipe(process.stderr);
|
|
54
|
+
child.on("error", (error) => {
|
|
55
|
+
rmSync(staging, { recursive: true, force: true });
|
|
56
|
+
done({ code: 1, error });
|
|
57
|
+
});
|
|
58
|
+
child.on("exit", (code) => {
|
|
59
|
+
if (code !== 0) {
|
|
60
|
+
rmSync(staging, { recursive: true, force: true });
|
|
61
|
+
done({ code: code ?? 1 });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
probeMcpRuntime(staging);
|
|
66
|
+
try {
|
|
67
|
+
probeMcpRuntime();
|
|
68
|
+
rmSync(staging, { recursive: true, force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
try {
|
|
71
|
+
renameSync(staging, MCP_RUNTIME);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Another first run can publish its complete staging
|
|
74
|
+
// directory first. Accept that winner only after the
|
|
75
|
+
// same full probe; never execute a partial cache.
|
|
76
|
+
probeMcpRuntime();
|
|
77
|
+
rmSync(staging, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
done({ code: 0 });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
rmSync(staging, { recursive: true, force: true });
|
|
83
|
+
done({ code: 1, error });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runMcp(rest) {
|
|
90
|
+
if (!existsSync(join(PKG_ROOT, "mcp", "server.mjs")) || !existsSync(join(MCP_RUNTIME_SOURCE, "package-lock.json"))) {
|
|
91
|
+
console.error("cozyclay: this build does not include the MCP server runtime.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let server;
|
|
96
|
+
try {
|
|
97
|
+
server = probeMcpRuntime();
|
|
98
|
+
} catch {
|
|
99
|
+
console.error("cozyclay: installing MCP server dependencies (one-time per CozyClay version)...");
|
|
100
|
+
const result = await installMcpRuntime();
|
|
101
|
+
if (result.code !== 0) {
|
|
102
|
+
console.error(`cozyclay: npm ci failed${result.error ? `: ${result.error.message}` : ` (exit ${result.code})`}. Check your network connection and retry.`);
|
|
103
|
+
console.error(`cozyclay: the published package was not changed; remove ${JSON.stringify(MCP_RUNTIME)} before retrying a damaged cache.`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
server = probeMcpRuntime();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error(`cozyclay: MCP runtime is incomplete after npm ci: ${error.message}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const child = spawn(process.execPath, [server, ...rest], { stdio: "inherit" });
|
|
114
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
115
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
5
|
+
const child = spawn(command, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
|
|
6
|
+
child.on("error", () => {
|
|
7
|
+
/* headless box, no browser: the URL is printed anyway */
|
|
8
|
+
});
|
|
9
|
+
child.unref();
|
|
10
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_SIGNATURE_PUBLIC_KEY = "MCowBQYDK2VwAyEAiDLutMZ8CX/NzpuQF/juhT8v9QwPBUvIOHFRqEZoG3U=";
|
|
6
|
+
|
|
7
|
+
const CONTENT_ROOTS = [
|
|
8
|
+
"bin",
|
|
9
|
+
"dist",
|
|
10
|
+
"src",
|
|
11
|
+
"tools",
|
|
12
|
+
"mcp/runtime",
|
|
13
|
+
"mcp/server.mjs",
|
|
14
|
+
"mcp/live-hub.mjs",
|
|
15
|
+
"mcp/ardy-prompts.mjs",
|
|
16
|
+
"mcp/package.json",
|
|
17
|
+
"mcp/README.md",
|
|
18
|
+
"mcp/LIVE-PROTOCOL.md",
|
|
19
|
+
"package.json",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"LICENSES",
|
|
24
|
+
"LICENSING.md",
|
|
25
|
+
"THIRD_PARTY_NOTICES.md",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function ignored(relativePath) {
|
|
29
|
+
return relativePath === "dist/cozyclay-package.json"
|
|
30
|
+
|| (/^dist\/media\/[^/]+\.mp4$/i.test(relativePath))
|
|
31
|
+
|| relativePath.startsWith("tools/ardy/out/")
|
|
32
|
+
|| /^tools\/qa-[^/]+\.mjs$/i.test(relativePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectFiles(path, packageRoot, output) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === "ENOTDIR") {
|
|
41
|
+
const relativePath = relative(packageRoot, path).split(sep).join("/");
|
|
42
|
+
if (!ignored(relativePath)) output.push({ path, relativePath });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (error?.code === "ENOENT") return;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const child = join(path, entry.name);
|
|
50
|
+
const relativePath = relative(packageRoot, child).split(sep).join("/");
|
|
51
|
+
if (ignored(relativePath)) continue;
|
|
52
|
+
if (entry.isDirectory()) collectFiles(child, packageRoot, output);
|
|
53
|
+
if (entry.isFile()) output.push({ path: child, relativePath });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function computePackageDigest(packageRoot) {
|
|
58
|
+
const absoluteRoot = resolve(packageRoot);
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const entry of CONTENT_ROOTS) collectFiles(join(absoluteRoot, entry), absoluteRoot, files);
|
|
61
|
+
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
62
|
+
const hash = createHash("sha256");
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
hash.update(file.relativePath);
|
|
65
|
+
hash.update("\0");
|
|
66
|
+
hash.update(readFileSync(file.path));
|
|
67
|
+
hash.update("\0");
|
|
68
|
+
}
|
|
69
|
+
return hash.digest("hex");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function verifyPackageMarker(
|
|
73
|
+
markerFile,
|
|
74
|
+
packageRoot,
|
|
75
|
+
packageMetadata,
|
|
76
|
+
publicKeyBase64 = PACKAGE_SIGNATURE_PUBLIC_KEY,
|
|
77
|
+
) {
|
|
78
|
+
try {
|
|
79
|
+
const marker = JSON.parse(readFileSync(markerFile, "utf8"));
|
|
80
|
+
const payload = JSON.parse(marker.payload);
|
|
81
|
+
if (payload.distribution !== "npm") return false;
|
|
82
|
+
if (payload.package !== "cozyclay") return false;
|
|
83
|
+
if (payload.version !== packageMetadata.version) return false;
|
|
84
|
+
if (payload.repository !== "NomaDamas/CozyClay") return false;
|
|
85
|
+
if (payload.content_sha256 !== computePackageDigest(packageRoot)) return false;
|
|
86
|
+
const publicKey = createPublicKey({
|
|
87
|
+
key: Buffer.from(publicKeyBase64, "base64"),
|
|
88
|
+
format: "der",
|
|
89
|
+
type: "spki",
|
|
90
|
+
});
|
|
91
|
+
return verify(null, Buffer.from(marker.payload), publicKey, Buffer.from(marker.signature, "base64"));
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|