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.
Files changed (101) hide show
  1. package/CHANGELOG.md +130 -6
  2. package/LICENSE +67 -80
  3. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  4. package/LICENSING.md +32 -0
  5. package/README.md +69 -12
  6. package/THIRD_PARTY_NOTICES.md +20 -2
  7. package/bin/cozyclay.mjs +230 -88
  8. package/bin/mcp-runtime.mjs +115 -0
  9. package/bin/open-browser.mjs +10 -0
  10. package/bin/package-signature.mjs +95 -0
  11. package/bin/telemetry-state.mjs +111 -0
  12. package/bin/update-check.mjs +158 -0
  13. package/dist/ai-camera-control/index.html +4 -5
  14. package/dist/app/index.html +3 -4
  15. package/dist/assets/app-CqOk49Bk.css +1 -0
  16. package/dist/assets/app-G55dPEDB.js +4811 -0
  17. package/dist/assets/module-MzlRiBjI.js +3 -0
  18. package/dist/cozyclay-package.json +1 -0
  19. package/dist/favicon.ico +0 -0
  20. package/dist/icons/apple-touch-icon.png +0 -0
  21. package/dist/icons/icon-192.png +0 -0
  22. package/dist/icons/icon-32.png +0 -0
  23. package/dist/icons/icon-512.png +0 -0
  24. package/dist/icons/icon-maskable-512.png +0 -0
  25. package/dist/index.html +27 -23
  26. package/dist/sw.js +2 -1
  27. package/mcp/LIVE-PROTOCOL.md +93 -12
  28. package/mcp/README.md +28 -10
  29. package/mcp/ardy-prompts.mjs +96 -78
  30. package/mcp/live-hub.mjs +231 -17
  31. package/mcp/package.json +5 -2
  32. package/mcp/runtime/package-lock.json +1211 -0
  33. package/mcp/runtime/package.json +16 -0
  34. package/mcp/server.mjs +798 -184
  35. package/package.json +32 -8
  36. package/src/App.jsx +2744 -713
  37. package/src/analytics-toggle.jsx +24 -0
  38. package/src/analytics.js +386 -0
  39. package/src/ardy/motion-edit.js +172 -0
  40. package/src/ardy/pose-pin.js +97 -0
  41. package/src/ardy/root-drop.js +180 -0
  42. package/src/ardy/timeline-resize.js +3 -1
  43. package/src/ardy/timeline.jsx +362 -73
  44. package/src/ardy/waypoints.js +4 -1
  45. package/src/asset-pane.jsx +339 -21
  46. package/src/asset-shelf.js +82 -0
  47. package/src/camera-block.js +42 -0
  48. package/src/camera-follow.js +149 -27
  49. package/src/camera-move.js +45 -19
  50. package/src/camera-rail-schedule.js +6 -0
  51. package/src/cuts.js +67 -54
  52. package/src/dualview.jsx +45 -5
  53. package/src/generation/generation-request.js +17 -0
  54. package/src/generation/use-generation.js +3 -14
  55. package/src/hierarchy-model.js +5 -3
  56. package/src/live-control.js +22 -2
  57. package/src/main.jsx +2 -0
  58. package/src/mp4-muxer.js +52 -0
  59. package/src/object-gizmo.jsx +211 -16
  60. package/src/offscreen-export.js +166 -0
  61. package/src/otio.js +217 -0
  62. package/src/planview.jsx +18 -10
  63. package/src/pose-extract/detector.js +7 -2
  64. package/src/pose-extract/image-frame.js +40 -0
  65. package/src/pose-extract/index.js +2 -1
  66. package/src/pose-extract/take.js +27 -0
  67. package/src/poses.js +19 -265
  68. package/src/posestudio.jsx +82 -8
  69. package/src/project-poses.js +9 -0
  70. package/src/project.js +86 -2
  71. package/src/pwa.js +6 -0
  72. package/src/room.jsx +77 -4
  73. package/src/sample-at.js +136 -0
  74. package/src/scene-asset-cache.js +137 -96
  75. package/src/scene-assets.js +94 -4
  76. package/src/scene-objects.js +82 -19
  77. package/src/scenes.js +34 -6
  78. package/src/shot-authoring.js +65 -28
  79. package/src/shot.js +53 -13
  80. package/src/source-offer.jsx +12 -0
  81. package/src/stable-items.js +83 -0
  82. package/src/styles.css +882 -115
  83. package/src/ui.jsx +32 -4
  84. package/src/usd-camera.js +145 -0
  85. package/tools/ardy/BRIDGE.md +8 -5
  86. package/tools/ardy/README.md +15 -0
  87. package/tools/ardy/artifacts.mjs +34 -0
  88. package/tools/ardy/bridge.mjs +32 -11
  89. package/tools/ardy/extract.mjs +16 -11
  90. package/tools/ardy/footage.mjs +15 -10
  91. package/tools/ardy/runners/local.mjs +4 -1
  92. package/tools/ardy/setup-text-encoder.py +14 -4
  93. package/tools/dev-full.mjs +63 -5
  94. package/tools/generation/bridge.mjs +14 -1
  95. package/tools/metrics/archive-traffic.mjs +1 -1
  96. package/tools/metrics-snapshot.mjs +98 -0
  97. package/tools/prepare-package.mjs +50 -0
  98. package/tools/process-supervisor.mjs +108 -2
  99. package/tools/run-tests.mjs +173 -0
  100. package/dist/assets/app-B3U5aut1.js +0 -4811
  101. 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 the sidecar on 5181 (the job Vite's dev proxy does),
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 { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
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 BRIDGE_PORT = Number(process.env.COZYCLAY_BRIDGE_PORT ?? 5181);
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 > 65535) {
66
- console.error("cozyclay: --port must be a port number");
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/HaD0Yun/CozyClay";
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: BRIDGE_PORT, path: req.url, method: req.method, headers: req.headers },
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
- writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), ...patch }, null, "\t"));
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/HaD0Yun/CozyClay", {
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
- function openBrowser(url) {
184
- const cmd =
185
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
186
- const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
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
- * `cozyclay mcp` hands over to the MCP server.
195
- *
196
- * Its dependencies are NOT the launcher's: the studio path stays dependency
197
- * free on purpose, and the MCP SDK is a 95-package tree nobody who only wants
198
- * a viewport should download. So the server ships with the package, its deps
199
- * are installed on demand, and a missing one produces an exact instruction
200
- * rather than a stack trace.
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
- // The deps must land in the PACKAGE root, not in mcp/: server.mjs imports
209
- // ../src/*.js, and those files resolve `three` from cozyclay/ itself.
210
- try {
211
- await import("@modelcontextprotocol/sdk/server/mcp.js");
212
- await import("three");
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
- const child = spawn(process.execPath, [server, ...rest], { stdio: "inherit" });
228
- child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
229
- }
230
-
231
- const argv = process.argv.slice(2);
232
- if (argv[0] === "mcp") {
233
- await runMcp(argv.slice(1));
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
- console.log(HELP);
239
- process.exit(0);
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
- const pkg = JSON.parse(await import("node:fs").then((fs) => fs.promises.readFile(join(PKG_ROOT, "package.json"), "utf8")));
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
- bridge = spawn(process.execPath, [BRIDGE], { cwd: PKG_ROOT, stdio: "inherit" });
259
- bridge.on("error", () => {
260
- bridge = null;
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
- const server = createServer((req, res) => {
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
- const rel = decodeURIComponent(url.pathname);
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 ${opts.port + 1}.`);
315
- if (bridge) bridge.kill("SIGTERM");
316
- process.exit(1);
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, opts.host, () => {
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://${opts.host}:${opts.port}/app/`;
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
+ }