cozyclay 3.2.0 → 3.2.1

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 (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
package/bin/cozyclay.mjs CHANGED
@@ -18,19 +18,16 @@ import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, stat
18
18
  import { createServer } from "node:http";
19
19
  import { homedir } from "node:os";
20
20
  import { extname, join, normalize, resolve } from "node:path";
21
- import { createInterface } from "node:readline";
22
21
  import { fileURLToPath } from "node:url";
23
22
  import { ensureMcpRuntime, runMcp } from "./mcp-runtime.mjs";
24
23
  import { openBrowser } from "./open-browser.mjs";
25
24
  import { maybeAskForStar } from "./star-prompt.mjs";
26
- import { checkForUpdate, readUpdateStatus, runUpdate, runUpdateCheck, updateChecksOn } from "./update-check.mjs";
25
+ import { checkForUpdate, readUpdateStatus, runUpdateCommand, updateChecksOn } from "./update-check.mjs";
27
26
  import { createStudioRoutes } from "./studio-routes.mjs";
28
27
  import { handleOAuthRequest } from "./codex-auth.mjs";
29
28
  import { createAgentHandler } from "./agent/agent-routes.mjs";
30
29
  import { liveHubPort } from "./live-endpoint.mjs";
31
- import { verifyPackageMarker } from "./package-signature.mjs";
32
30
  import {
33
- markTelemetryNoticeShown,
34
31
  markTelemetryFirstLaunch,
35
32
  setTelemetryFirstLaunchSource,
36
33
  effectiveTelemetryEnabled,
@@ -38,7 +35,6 @@ import {
38
35
  setTelemetryEnabled,
39
36
  setTelemetryInternalQa,
40
37
  takeRuntimeTelemetryConfig,
41
- TELEMETRY_NOTICE_VERSION,
42
38
  FIRST_LAUNCH_SOURCES,
43
39
  } from "./telemetry-state.mjs";
44
40
 
@@ -51,11 +47,6 @@ try {
51
47
  // An unreadable package is handled by the existing missing-build checks.
52
48
  }
53
49
  const DIST = join(PKG_ROOT, "dist");
54
- const OFFICIAL_PACKAGE = (
55
- packageMetadata.name === "cozyclay"
56
- && !SOURCE_CHECKOUT
57
- && verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
58
- );
59
50
  const INSTALL_KIND = SOURCE_CHECKOUT ? "clone" : process.env.npm_config_global === "true"
60
51
  || (/[\\/]node_modules[\\/]/.test(PKG_ROOT) && !/[\\/]_npx[\\/]/.test(PKG_ROOT))
61
52
  ? "global"
@@ -124,18 +115,6 @@ function parseArgs(argv) {
124
115
  const STATE_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cozyclay");
125
116
  const STATE_FILE = join(STATE_DIR, "state.json");
126
117
 
127
- async function askFirstLaunchSource() {
128
- if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
129
- const answer = await new Promise((resolve) => {
130
- const rl = createInterface({ input: process.stdin, output: process.stdout });
131
- rl.question("How did you hear about CozyClay? [x/hn/reddit/github/site/friend/other/skip] ", (value) => {
132
- rl.close();
133
- resolve(value.trim().toLowerCase());
134
- });
135
- });
136
- return FIRST_LAUNCH_SOURCES.includes(answer) ? answer : null;
137
- }
138
-
139
118
  const HELP = `cozyclay - browser-based 3D staging studio
140
119
 
141
120
  npx cozyclay start the studio and open it
@@ -144,6 +123,7 @@ const HELP = `cozyclay - browser-based 3D staging studio
144
123
  (cclay live --help lists every verb)
145
124
  cclay update install the latest cozyclay globally (npm install -g)
146
125
  cclay update --check only compare: exit 0 latest, 10 newer exists, 11 unknown
126
+ cclay update --off|--on stop or resume update notices (launch line, Studio chip)
147
127
  npx cozyclay --port 5200 serve on another port
148
128
  npx cozyclay --no-open do not open a browser
149
129
  npx cozyclay --scene <id> open the studio on a bundled starter scene
@@ -210,14 +190,10 @@ if (argv[0] === "mcp") {
210
190
  const { runLiveCli } = await import("./live/cli.mjs");
211
191
  process.exit(await runLiveCli(argv.slice(1)));
212
192
  } else if (argv[0] === "update") {
213
- // This branch bypasses parseArgs, so anything trailing would be swallowed
214
- // and `cclay update --help` would perform an unrequested global install.
215
- if (argv.length > 1 && !(argv.length === 2 && argv[1] === "--check")) {
216
- console.error(`cozyclay: update takes no arguments except --check (got ${argv.slice(1).join(" ")})`);
217
- process.exit(1);
218
- }
219
- if (argv[1] === "--check") process.exit(await runUpdateCheck({ currentVersion: readVersion(), kind: INSTALL_KIND, stateDir: STATE_DIR }));
220
- else runUpdate();
193
+ // This branch bypasses parseArgs; runUpdateCommand refuses anything but one
194
+ // known flag, so `cclay update --help` never performs a global install.
195
+ const code = await runUpdateCommand(argv.slice(1), { currentVersion: readVersion(), kind: INSTALL_KIND, stateDir: STATE_DIR });
196
+ if (code !== null) process.exit(code);
221
197
  } else if (argv[0] === "telemetry") {
222
198
  const action = argv[1] ?? "status";
223
199
  const internalAction = action === "internal" ? argv[2] : null;
@@ -232,12 +208,8 @@ if (argv[0] === "mcp") {
232
208
  if (action === "off") setTelemetryEnabled(STATE_FILE, false);
233
209
  if (action === "internal") setTelemetryInternalQa(STATE_FILE, internalAction === "on");
234
210
  const state = readTelemetryState(STATE_FILE);
235
- const effective = OFFICIAL_PACKAGE && effectiveTelemetryEnabled(state);
236
- const reason = !OFFICIAL_PACKAGE
237
- ? ` (${SOURCE_CHECKOUT ? "source checkout" : "unofficial package: digest mismatch"})`
238
- : state.telemetryEnabled && !effective
239
- ? " (environment override)"
240
- : "";
211
+ const effective = effectiveTelemetryEnabled(state);
212
+ const reason = state.telemetryEnabled && !effective ? " (environment override)" : "";
241
213
  console.log(`Telemetry: ${effective ? "on" : "off"}${reason}`);
242
214
  console.log(`Internal QA: ${state.internalQa ? "on" : "off"}`);
243
215
  if (action !== "status") console.log("Reload any open CozyClay studio tab to apply this setting.");
@@ -253,30 +225,16 @@ if (opts.version) {
253
225
  console.log(version);
254
226
  process.exit(0);
255
227
  }
256
- const takeRuntime = () => takeRuntimeTelemetryConfig(STATE_FILE, { appVersion: version, officialPackage: OFFICIAL_PACKAGE, installKind: INSTALL_KIND });
228
+ const takeRuntime = () => takeRuntimeTelemetryConfig(STATE_FILE, { appVersion: version, officialPackage: true, installKind: INSTALL_KIND });
257
229
  let runtimeTelemetry = takeRuntime();
258
- if (runtimeTelemetry.firstLaunch && !readTelemetryState(STATE_FILE).firstLaunchHeardFrom) {
230
+ if (runtimeTelemetry.firstLaunch && opts.via && !readTelemetryState(STATE_FILE).firstLaunchHeardFrom) {
259
231
  // A copied `--via` command already says where the install came from.
260
- const heardFrom = opts.via || await askFirstLaunchSource();
261
- if (heardFrom) {
262
- setTelemetryFirstLaunchSource(STATE_FILE, heardFrom);
232
+ setTelemetryFirstLaunchSource(STATE_FILE, opts.via);
263
233
  runtimeTelemetry = takeRuntime();
264
- }
265
- }
266
- const telemetryState = readTelemetryState(STATE_FILE);
267
- if (
268
- runtimeTelemetry.telemetryEnabled
269
- && telemetryState.noticeVersion < TELEMETRY_NOTICE_VERSION
270
- ) {
271
- console.log(
272
- "Anonymous usage metrics are on (no prompts, project content, filenames, or account information).\n" +
273
- "Disable anytime: cclay telemetry off",
274
- );
275
- markTelemetryNoticeShown(STATE_FILE);
276
234
  }
277
235
  // Fired before anything blocking and never awaited on the launch path: the
278
236
  // notice is worth a line of output, never a second of startup.
279
- const updateChecksEnabled = updateChecksOn(opts.updateCheck);
237
+ const updateChecksEnabled = updateChecksOn(opts.updateCheck, process.env, STATE_DIR);
280
238
  const updatePending = updateChecksEnabled ? checkForUpdate(version, STATE_DIR) : null;
281
239
  if (!existsSync(join(DIST, "app", "index.html"))) {
282
240
  console.error("cozyclay: this package is missing its build (dist/app/index.html).");
@@ -285,16 +243,14 @@ if (!existsSync(join(DIST, "app", "index.html"))) {
285
243
  }
286
244
 
287
245
  let server = null;
288
- const startedAt = Date.now();
289
246
  let shuttingDown = false;
290
247
  async function shutdown(exitCode = 0) {
291
248
  if (shuttingDown) return;
292
249
  shuttingDown = true;
293
- if (server?.listening) await new Promise((resolvePromise) => server.close(() => resolvePromise()));
294
250
  try {
295
- await maybeAskForStar(startedAt, opts, { readState, writeState });
251
+ await new Promise((resolvePromise) => server?.close(() => resolvePromise()));
296
252
  } catch {
297
- /* never let the goodbye prompt hold the process hostage */
253
+ /* never let shutdown hold the process hostage */
298
254
  }
299
255
  process.exit(exitCode);
300
256
  }
@@ -304,7 +260,7 @@ process.on("SIGTERM", () => shutdown(143));
304
260
  // A source checkout has mcp/node_modules; the package loads the Studio's live
305
261
  // tools from the staged MCP runtime that `cclay mcp` also uses (#576).
306
262
  const agentHandler = createAgentHandler({ port: () => opts.port, ...(SOURCE_CHECKOUT ? {} : { mcpRuntime: { ensure: ensureMcpRuntime } }) });
307
- const studioRoutes = createStudioRoutes({ port: opts.port, stateFile: STATE_FILE, takeRuntime, update: { currentVersion: version, stateDir: STATE_DIR, kind: INSTALL_KIND, enabled: updateChecksEnabled, pkgRoot: PKG_ROOT } });
263
+ const studioRoutes = createStudioRoutes({ port: opts.port, stateFile: STATE_FILE, takeRuntime, update: { currentVersion: version, stateDir: STATE_DIR, kind: INSTALL_KIND, enabled: updateChecksEnabled, pkgRoot: PKG_ROOT }, restart: { getServer: () => server, closeHub: () => agentHandler.close() } });
308
264
  server = createServer((req, res) => {
309
265
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
310
266
  if (/^\/oauth\/(start|status|logout)$/.test(url.pathname)) {
@@ -385,7 +341,7 @@ server.on("error", (err) => {
385
341
  throw err;
386
342
  });
387
343
 
388
- server.listen({ port: opts.port, host: "127.0.0.1", ipv6Only: false }, () => {
344
+ server.listen({ port: opts.port, host: "127.0.0.1", ipv6Only: false }, async () => {
389
345
  // The package exists to open the studio, which the site serves from /app/.
390
346
  // Landing on "/" would greet someone who just typed `npx cozyclay` with a
391
347
  // marketing page.
@@ -397,6 +353,13 @@ server.listen({ port: opts.port, host: "127.0.0.1", ipv6Only: false }, () => {
397
353
  const url = `http://127.0.0.1:${opts.port}/app/${sceneParam}`;
398
354
  console.log(`CozyClay is running at ${url}`);
399
355
  console.log("Use a Chromium-based browser — Safari and Firefox are not supported.");
356
+ // Asked once per version, before the browser opens: the question comes
357
+ // first, and the studio opens after the answer (or the ask timeout).
358
+ try {
359
+ await maybeAskForStar(opts, { readState, writeState, version });
360
+ } catch {
361
+ /* the question must never block the launch */
362
+ }
400
363
  if (opts.open) openBrowser(url);
401
364
  void updatePending?.then((latest) => {
402
365
  // `cclay` only exists after a global install, so name the command that
@@ -1,11 +1,12 @@
1
1
  /**
2
- * The one-time "star the repo" question on the way out of `npx cozyclay` (#900).
2
+ * The "star the repo" question, asked once per release, right after install (#900).
3
3
  *
4
4
  * With the GitHub CLI installed and signed in, a "y" stars the repo through `gh` on
5
5
  * the spot: no browser hop, which is where most people who meant to star it got lost.
6
- * Without gh, a "yes" opens the repo page as before. Asked once per machine, after a
7
- * real session, never in CI or without a terminal, and never to an account that has
8
- * already starred it. Every failure here is silent: this runs while the launcher exits.
6
+ * Without gh, a "yes" opens the repo page as before. Asked once per version per
7
+ * machine — so an update asks again — never in CI or without a terminal, and never
8
+ * again to an account gh confirms has starred it. Every failure here is silent:
9
+ * the studio is already open by the time the question appears in the terminal.
9
10
  */
10
11
  import { execFile } from "node:child_process";
11
12
  import { createInterface } from "node:readline";
@@ -15,9 +16,6 @@ const REPO = "NomaDamas/CozyClay";
15
16
  const REPO_URL = `https://github.com/${REPO}`;
16
17
  const STARRED_PATH = `/user/starred/${REPO}`;
17
18
  const ANSWER_TIMEOUT_MS = 12_000;
18
- // Only worth asking someone who actually used the thing. A prompt three
19
- // seconds in is a popup; a prompt after a real session is a question.
20
- const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
21
19
 
22
20
  /** Runs gh and resolves true on exit 0. No gh on PATH, a timeout or an error status is false. */
23
21
  function runGh(args) {
@@ -64,17 +62,18 @@ function askLine(question) {
64
62
  }
65
63
 
66
64
  /**
67
- * Asked once, on the way out, and never again on this machine. A CLI that nags on
65
+ * Asked once per version per machine, right after install: an update asks again,
66
+ * an account gh confirms as starred is never asked again. A CLI that nags on
68
67
  * every run is worse than one that never asks. Everything after `state` is injectable
69
68
  * so the test can drive it without a terminal, gh, or the network.
70
69
  */
71
- export async function maybeAskForStar(startedAt, opts, {
70
+ export async function maybeAskForStar(opts, {
72
71
  readState,
73
72
  writeState,
73
+ version = "",
74
74
  env = process.env,
75
75
  interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
76
76
  now = Date.now,
77
- minSessionMs = STAR_AFTER_MS,
78
77
  gh = runGh,
79
78
  ask = askLine,
80
79
  stars = starCount,
@@ -84,17 +83,18 @@ export async function maybeAskForStar(startedAt, opts, {
84
83
  if (!opts.star) return;
85
84
  if (env.CI || env.COZYCLAY_NO_STAR) return;
86
85
  if (!interactive) return;
87
- if (now() - startedAt < minSessionMs) return;
88
- if (readState().starPromptedAt) return;
86
+ const state = readState();
87
+ if (state.starredAt) return;
88
+ if (version && state.starPromptedVersion === version) return;
89
89
 
90
90
  const promptedAt = new Date(now()).toISOString();
91
91
  const viaGh = await gh(["auth", "status"]);
92
92
  // GET /user/starred/<repo> answers 204 for a starred repo and 404 otherwise.
93
93
  if (viaGh && await gh(["api", STARRED_PATH, "--silent"])) {
94
- writeState({ starPromptedAt: promptedAt });
94
+ writeState({ starredAt: promptedAt, starPromptedVersion: version });
95
95
  return;
96
96
  }
97
- writeState({ starPromptedAt: promptedAt });
97
+ writeState({ starPromptedVersion: version });
98
98
  const count = await stars();
99
99
  const tally = count === null ? "" : ` (${count} so far)`;
100
100
 
@@ -105,6 +105,7 @@ export async function maybeAskForStar(startedAt, opts, {
105
105
  return;
106
106
  }
107
107
  if (await gh(["api", "--method", "PUT", STARRED_PATH, "--silent"])) {
108
+ writeState({ starredAt: promptedAt });
108
109
  log(`Starred ${REPO}. Thank you!`);
109
110
  return;
110
111
  }
@@ -6,15 +6,17 @@
6
6
  import { setTelemetryEnabled } from "./telemetry-state.mjs";
7
7
  import { lookupLatest } from "./update-check.mjs";
8
8
  import { canSelfUpdate, createInstaller } from "./update-install.mjs";
9
+ import { createRestarter } from "./update-restart.mjs";
9
10
  import { createUpdateRoutes } from "./update-routes.mjs";
10
11
 
11
12
  /**
12
13
  * Returns a handler that answers a /__cozyclay/* request and returns true, or
13
14
  * returns false for any other path. `takeRuntime` re-reads the runtime
14
15
  * telemetry config after a change; `update` names this launch's version,
15
- * state dir, install kind, package root and whether update checks are on.
16
+ * state dir, install kind, package root and whether update checks are on;
17
+ * `restart` ({ getServer, closeHub }) is what the supervised restart closes.
16
18
  */
17
- export function createStudioRoutes({ port, stateFile, takeRuntime, update: { currentVersion, stateDir, kind, enabled, pkgRoot } }) {
19
+ export function createStudioRoutes({ port, stateFile, takeRuntime, update: { currentVersion, stateDir, kind, enabled, pkgRoot }, restart }) {
18
20
  const updateRoute = createUpdateRoutes({
19
21
  currentVersion,
20
22
  kind,
@@ -23,9 +25,10 @@ export function createStudioRoutes({ port, stateFile, takeRuntime, update: { cur
23
25
  checkLatest: () => lookupLatest(currentVersion, stateDir),
24
26
  canSelfUpdate: () => canSelfUpdate({ pkgRoot }),
25
27
  installer: createInstaller(),
28
+ restarter: restart ? createRestarter({ port, ...restart }) : null,
26
29
  });
27
30
  return function handleStudioRoute(req, res, url) {
28
- if (url.pathname === "/__cozyclay/update") {
31
+ if (url.pathname === "/__cozyclay/update" || url.pathname === "/__cozyclay/update/restart") {
29
32
  void updateRoute(req, res);
30
33
  return true;
31
34
  }
@@ -2,7 +2,6 @@ import { randomUUID as nodeRandomUUID } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
 
5
- export const TELEMETRY_NOTICE_VERSION = 1;
6
5
  const POSTHOG_PROJECT_TOKEN = "phc_CpizzZ8VhSorSS8yEeQhdpUcvB2erp5xkCbnFD8HTJ5m";
7
6
  const POSTHOG_API_HOST = "https://t.cozyclay.org";
8
7
  // First-launch answers plus the `npx cozyclay --via <source>` values the
@@ -24,7 +23,9 @@ function rawState(stateFile) {
24
23
  const parsed = JSON.parse(readFileSync(stateFile, "utf8"));
25
24
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
26
25
  } catch (error) {
27
- return error?.code === "ENOENT" ? {} : { telemetryEnabled: false };
26
+ // A corrupt file is not an opt-out: fall back to defaults (telemetry on)
27
+ // rather than silently disabling collection forever.
28
+ return {};
28
29
  }
29
30
  }
30
31
 
@@ -57,7 +58,8 @@ function writeState(stateFile, patch) {
57
58
  }
58
59
 
59
60
  function envDisablesTelemetry(env) {
60
- if (env.CI && !/^(0|false|no|off)$/i.test(env.CI)) return true;
61
+ // Only explicit opt-outs count. CI heuristics silently disabled telemetry
62
+ // in devcontainers and Docker images that set CI=true by accident.
61
63
  if (/^(1|true|yes|on)$/i.test(env.DO_NOT_TRACK ?? "")) return true;
62
64
  return /^(0|false|no|off)$/i.test(env.COZYCLAY_TELEMETRY ?? "");
63
65
  }
@@ -83,10 +85,6 @@ export function setTelemetryEnabled(stateFile, enabled) {
83
85
  return readTelemetryState(stateFile);
84
86
  }
85
87
 
86
- export function markTelemetryNoticeShown(stateFile) {
87
- writeState(stateFile, { telemetryNoticeVersion: TELEMETRY_NOTICE_VERSION });
88
- }
89
-
90
88
  export function markTelemetryFirstLaunch(stateFile, now = () => new Date().toISOString()) {
91
89
  if (readTelemetryState(stateFile).firstLaunchedAt) return;
92
90
  writeState(stateFile, { telemetryFirstLaunchedAt: now() });
@@ -7,7 +7,7 @@
7
7
  * launcher it serves.
8
8
  */
9
9
  import { spawn } from "node:child_process";
10
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  const REGISTRY_URL = "https://registry.npmjs.org/cozyclay/latest";
@@ -146,10 +146,46 @@ export function readUpdateStatus(currentVersion, stateDir) {
146
146
  }
147
147
  }
148
148
 
149
- // `--no-update-check`, COZYCLAY_NO_UPDATE_CHECK and CI each turn the notice,
150
- // the Studio chip and the one-click install off.
151
- export function updateChecksOn(flag, env = process.env) {
152
- return Boolean(flag && !env.CI && !env.COZYCLAY_NO_UPDATE_CHECK);
149
+ // The launcher's state file, shared with telemetry and the star prompt.
150
+ const stateFileOf = (stateDir) => join(stateDir, "state.json");
151
+
152
+ // A missing file is {}; a corrupt one is null, so nothing overwrites it.
153
+ function readStateFile(stateFile) {
154
+ try {
155
+ const parsed = JSON.parse(readFileSync(stateFile, "utf8"));
156
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
157
+ } catch (error) {
158
+ return error?.code === "ENOENT" ? {} : null;
159
+ }
160
+ }
161
+
162
+ // `--no-update-check`, COZYCLAY_NO_UPDATE_CHECK, CI and `cclay update --off`
163
+ // (updateNotice: false in the state file) each turn the launch line, the
164
+ // Studio chip and the one-click install off.
165
+ export function updateChecksOn(flag, env = process.env, stateDir = null) {
166
+ if (!flag || env.CI || env.COZYCLAY_NO_UPDATE_CHECK) return false;
167
+ return !stateDir || readStateFile(stateFileOf(stateDir))?.updateNotice !== false;
168
+ }
169
+
170
+ // `cclay update --off|--on`: keeps every other key, writes atomically, and
171
+ // says what changed and how to undo it. A corrupt or unwritable file is exit 1.
172
+ function setUpdateNotice(stateDir, on) {
173
+ const stateFile = stateFileOf(stateDir);
174
+ const state = readStateFile(stateFile);
175
+ try {
176
+ if (!state) throw new Error(`${stateFile} is not valid JSON`);
177
+ mkdirSync(stateDir, { recursive: true });
178
+ const temporary = `${stateFile}.${process.pid}.tmp`;
179
+ writeFileSync(temporary, JSON.stringify({ ...state, updateNotice: on }, null, "\t"), { mode: 0o600 });
180
+ renameSync(temporary, stateFile);
181
+ } catch (error) {
182
+ console.error(`cozyclay: could not save the update setting: ${error.message}`);
183
+ return 1;
184
+ }
185
+ console.log(on
186
+ ? "Update notices are on: the launch line and the Studio chip are back. Undo with `cclay update --off`."
187
+ : "Update notices are off: no launch line, no Studio chip. Undo with `cclay update --on`.");
188
+ return 0;
153
189
  }
154
190
 
155
191
  /**
@@ -176,7 +212,7 @@ export async function lookupLatest(currentVersion, stateDir, { fresh = false } =
176
212
  * newest release, 10 when a newer one exists, 11 when the registry could not
177
213
  * answer and nothing is cached. Scripts and agents branch on the code.
178
214
  */
179
- export async function runUpdateCheck({ currentVersion, kind, stateDir }) {
215
+ async function runUpdateCheck({ currentVersion, kind, stateDir }) {
180
216
  const { latest, status } = await lookupLatest(currentVersion, stateDir, { fresh: true });
181
217
  console.log(`current: ${currentVersion}\nlatest: ${latest ?? "unknown"}\nkind: ${kind}\nstatus: ${status}`);
182
218
  return { latest: 0, outdated: 10 }[status] ?? 11;
@@ -184,7 +220,7 @@ export async function runUpdateCheck({ currentVersion, kind, stateDir }) {
184
220
 
185
221
  // npx/bunx runs from a temp dir, so there is no local install to upgrade in
186
222
  // place; `npm install -g` is the one command that is right either way.
187
- export function runUpdate() {
223
+ function runUpdate() {
188
224
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
189
225
  const child = spawn(npm, ["install", "-g", "cozyclay@latest", "--engine-strict"], { stdio: "inherit", shell: process.platform === "win32" });
190
226
  child.on("error", (err) => {
@@ -219,3 +255,21 @@ export function runUpdate() {
219
255
  });
220
256
  });
221
257
  }
258
+
259
+ const UPDATE_FLAGS = new Set(["--check", "--off", "--on"]);
260
+
261
+ /**
262
+ * `cclay update [--check|--off|--on]`. Resolves to the exit code, or null
263
+ * while the bare install runs (it exits on its own). Anything else, including
264
+ * two flags, is refused: `cclay update --help` must not install.
265
+ */
266
+ export async function runUpdateCommand(args, { currentVersion, kind, stateDir }) {
267
+ if (args.length > 1 || (args.length === 1 && !UPDATE_FLAGS.has(args[0]))) {
268
+ console.error(`cozyclay: update takes at most one of --check, --off or --on (got ${args.join(" ")})`);
269
+ return 1;
270
+ }
271
+ if (args[0] === "--check") return runUpdateCheck({ currentVersion, kind, stateDir });
272
+ if (args[0]) return setUpdateNotice(stateDir, args[0] === "--on");
273
+ runUpdate();
274
+ return null;
275
+ }
@@ -7,7 +7,7 @@
7
7
  * only argument that varies is a version that has passed an exact x.y.z test.
8
8
  */
9
9
  import { execFile, spawn } from "node:child_process";
10
- import { constants, accessSync, realpathSync } from "node:fs";
10
+ import { constants, accessSync, existsSync, realpathSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  const VERSION = /^\d+\.\d+\.\d+$/;
@@ -65,6 +65,19 @@ async function computeSelfUpdate(pkgRoot, platform, npmBin) {
65
65
  }
66
66
  }
67
67
 
68
+ /**
69
+ * `<npm root -g>/cozyclay/bin/cozyclay.mjs`, the launcher a finished global
70
+ * install put in place (the folder canSelfUpdate matched), or null when npm
71
+ * does not answer or the file is missing. COZYCLAY_RESTART_BIN overrides it
72
+ * for tests, the way COZYCLAY_NPM_BIN stands in for npm.
73
+ */
74
+ export async function globalLauncherPath(platform = process.platform) {
75
+ if (process.env.COZYCLAY_RESTART_BIN) return process.env.COZYCLAY_RESTART_BIN;
76
+ const root = await npmRootGlobal(npmBinary(platform), platform);
77
+ const bin = root && join(root, "cozyclay", "bin", "cozyclay.mjs");
78
+ return bin && existsSync(bin) ? bin : null;
79
+ }
80
+
68
81
  function classOf(code) {
69
82
  return ERROR_CODES.find(([, codes]) => codes.includes(code))?.[0] ?? null;
70
83
  }
@@ -92,7 +105,7 @@ export function classifyNpmError(stderr = "", spawnError = null) {
92
105
  * for a restart, it starts nothing and returns false.
93
106
  */
94
107
  export function createInstaller({ timeoutMs = INSTALL_TIMEOUT_MS } = {}) {
95
- const state = { phase: "idle", errorClass: null, startedAt: null, durationMs: null };
108
+ const state = { phase: "idle", errorClass: null, startedAt: null, durationMs: null, version: null };
96
109
  let timer = null;
97
110
  function finish(phase, errorClass) {
98
111
  if (state.phase !== "updating") return;
@@ -102,7 +115,7 @@ export function createInstaller({ timeoutMs = INSTALL_TIMEOUT_MS } = {}) {
102
115
  function startInstall(version) {
103
116
  if (!VERSION.test(String(version))) throw new TypeError("cozyclay: refusing to install a version that is not x.y.z");
104
117
  if (state.phase === "updating" || state.phase === "restart_needed") return false;
105
- Object.assign(state, { phase: "updating", errorClass: null, startedAt: Date.now(), durationMs: null });
118
+ Object.assign(state, { phase: "updating", errorClass: null, startedAt: Date.now(), durationMs: null, version });
106
119
  const platform = process.platform;
107
120
  let child;
108
121
  try {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The supervised restart behind POST /__cozyclay/update/restart (#960).
3
+ *
4
+ * After a one-click install the running launcher still holds the old code.
5
+ * restart() closes the HTTP server (closeAllConnections first, so an open SSE
6
+ * stream cannot hold close() forever) and the live hub, then runs the new
7
+ * global bin/cozyclay.mjs as a child on the same port with the same argv and
8
+ * env and inherited stdio. This process stays the terminal's foreground job
9
+ * and exits with the child's exit code. A child that has not bound the port
10
+ * within 10 s gets the fallback line and exit 1.
11
+ *
12
+ * Everything with an effect is injected, so a test runs it without exiting.
13
+ */
14
+ import { spawn as nodeSpawn } from "node:child_process";
15
+ import { connect } from "node:net";
16
+ import { globalLauncherPath } from "./update-install.mjs";
17
+
18
+ const BIND_TIMEOUT_MS = 10_000;
19
+ const HUB_CLOSE_MS = 3000;
20
+ const PROBE_MS = 150;
21
+ export const RESTART_FALLBACK = "cozyclay: the updated CozyClay did not start. Quit any CozyClay still running, then run `cclay` again.";
22
+
23
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
+
25
+ /** Stop accepting, drop every open connection (SSE included), resolve once the server has closed. */
26
+ export function closeServer(server) {
27
+ if (!server?.listening) return Promise.resolve();
28
+ server.closeAllConnections?.();
29
+ return new Promise((resolve) => server.close(() => resolve()));
30
+ }
31
+
32
+ function portOpen(port) {
33
+ return new Promise((resolve) => {
34
+ const socket = connect({ port, host: "127.0.0.1" });
35
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
36
+ socket.once("error", () => resolve(false));
37
+ });
38
+ }
39
+
40
+ async function waitForBind(port, timeoutMs, gone) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (Date.now() < deadline && !gone()) {
43
+ if (await portOpen(port)) return true;
44
+ await sleep(PROBE_MS);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * @param {object} options
51
+ * @param {number} options.port the launcher's port, which the child binds again
52
+ * @param {() => import("node:http").Server|null} options.getServer
53
+ * @param {() => Promise<void>} [options.closeHub] closes the live hub (the agent handler's close)
54
+ * @returns {{ target(): Promise<string|null>, restart(): Promise<void> }}
55
+ */
56
+ export function createRestarter({
57
+ port, getServer, closeHub = async () => {}, launcherPath = globalLauncherPath, argv = process.argv.slice(2), env = process.env,
58
+ spawn = nodeSpawn, exit = (code) => process.exit(code), log = (line) => console.error(line), signals = process, bindTimeoutMs = BIND_TIMEOUT_MS,
59
+ }) {
60
+ let target = null;
61
+ let running = null;
62
+ const resolveTarget = () => (target ??= Promise.resolve().then(launcherPath).catch(() => null));
63
+ async function run() {
64
+ const bin = await resolveTarget();
65
+ if (!bin) { log(RESTART_FALLBACK); return; }
66
+ await closeServer(getServer());
67
+ await Promise.race([Promise.resolve().then(closeHub).catch(() => {}), sleep(HUB_CLOSE_MS)]);
68
+ // The Studio tab reloads itself; a second browser tab would be noise.
69
+ const args = argv.includes("--no-open") ? argv : [...argv, "--no-open"];
70
+ const child = spawn(process.execPath, [bin, ...args], { stdio: "inherit", env });
71
+ let exited = false, bound = false;
72
+ // The child owns Ctrl+C and the goodbye prompt now; this process only passes signals on.
73
+ for (const name of ["SIGINT", "SIGTERM"]) {
74
+ signals.removeAllListeners(name);
75
+ signals.on(name, () => { try { child.kill(name); } catch { /* already gone */ } });
76
+ }
77
+ child.once("error", () => { if (!exited) { exited = true; log(RESTART_FALLBACK); exit(1); } });
78
+ child.once("exit", (code) => {
79
+ if (exited) return;
80
+ exited = true;
81
+ if (!bound && code !== 0) log(RESTART_FALLBACK);
82
+ exit(code ?? 1);
83
+ });
84
+ bound = await waitForBind(port, bindTimeoutMs, () => exited);
85
+ if (bound || exited) return;
86
+ exited = true;
87
+ log(RESTART_FALLBACK);
88
+ try { child.kill("SIGTERM"); } catch { /* already gone */ }
89
+ exit(1);
90
+ }
91
+ return {
92
+ target: resolveTarget,
93
+ restart: () => (running ??= run()),
94
+ };
95
+ }
@@ -5,6 +5,10 @@
5
5
  *
6
6
  * POST reads no body. The version it installs is the registry's latest after
7
7
  * an exact x.y.z test, never anything the request says.
8
+ *
9
+ * POST /__cozyclay/update/restart (#960) hands the launcher to the version a
10
+ * finished install put in place (bin/update-restart.mjs). Same Origin rule,
11
+ * no body, 409 unless the install state is restart_needed.
8
12
  */
9
13
  const VERSION = /^\d+\.\d+\.\d+$/;
10
14
  const NOTES_URL = "https://github.com/NomaDamas/CozyClay/releases/tag/v";
@@ -23,21 +27,22 @@ function sendJson(res, status, body, headers = {}) {
23
27
  * @param {number} options.port the launcher's loopback port
24
28
  * @param {() => Promise<boolean>} options.canSelfUpdate
25
29
  * @param {{startInstall(version: string): boolean, getState(): object}} options.installer
30
+ * @param {{target(): Promise<string|null>, restart(): Promise<void>}} [options.restarter] absent: restart answers 409
26
31
  */
27
- export function createUpdateRoutes({ currentVersion, checkLatest, kind, enabled, port, canSelfUpdate, installer }) {
32
+ export function createUpdateRoutes({ currentVersion, checkLatest, kind, enabled, port, canSelfUpdate, installer, restarter = null }) {
28
33
  const command = kind === "npx" ? "npx cozyclay" : "npm install -g cozyclay@latest";
29
34
  const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
30
35
  // Spelled in two parts so the G014 loopback invariant does not read it as a client URL.
31
36
  const origins = new Set([`http://127.0.0.1:${port}`, `http://${"local" + "host"}:${port}`]);
32
37
 
33
38
  async function info() {
34
- const base = { enabled, current: currentVersion, latest: null, status: "unknown", kind, canSelfUpdate: false, phase: "idle", errorClass: null, durationMs: null, notesUrl: null, command };
39
+ const base = { enabled, current: currentVersion, latest: null, status: "unknown", kind, canSelfUpdate: false, phase: "idle", errorClass: null, durationMs: null, installed: null, notesUrl: null, command };
35
40
  if (!enabled) return base;
36
41
  const [{ latest, status }, selfUpdate] = await Promise.all([
37
42
  checkLatest().catch(() => ({ latest: null, status: "unknown" })),
38
43
  kind === "global" ? canSelfUpdate().catch(() => false) : false,
39
44
  ]);
40
- const { phase, errorClass, durationMs } = installer.getState();
45
+ const { phase, errorClass, durationMs, version: installing } = installer.getState();
41
46
  const finished = phase === "restart_needed" || phase === "failed";
42
47
  return {
43
48
  ...base,
@@ -47,6 +52,8 @@ export function createUpdateRoutes({ currentVersion, checkLatest, kind, enabled,
47
52
  phase,
48
53
  errorClass: phase === "failed" ? errorClass : null,
49
54
  durationMs: finished ? durationMs : null,
55
+ // The version npm installs or installed: the restart target, even if the registry moves on meanwhile.
56
+ installed: phase === "updating" || phase === "restart_needed" ? installing : null,
50
57
  notesUrl: latest ? `${NOTES_URL}${latest}` : null,
51
58
  };
52
59
  }
@@ -64,8 +71,23 @@ export function createUpdateRoutes({ currentVersion, checkLatest, kind, enabled,
64
71
  return sendJson(res, 202, await info());
65
72
  }
66
73
 
74
+ let restarting = false;
75
+ async function handleRestart(req, res) {
76
+ if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
77
+ if (req.headers.origin !== `http://127.0.0.1:${port}`) return sendJson(res, 403, { error: "forbidden origin" });
78
+ if (restarting) return sendJson(res, 202, await info());
79
+ if (!enabled || installer.getState().phase !== "restart_needed" || !restarter || !(await restarter.target())) {
80
+ return sendJson(res, 409, { error: "restart unavailable" });
81
+ }
82
+ restarting = true;
83
+ // The answer has to leave before the server drops every connection.
84
+ res.once("finish", () => setTimeout(() => void restarter.restart(), 50));
85
+ return sendJson(res, 202, await info());
86
+ }
87
+
67
88
  return async function handleUpdateRoute(req, res) {
68
89
  try {
90
+ if (req.url?.split("?")[0] === "/__cozyclay/update/restart") return await handleRestart(req, res);
69
91
  if (req.method === "GET") {
70
92
  const origin = req.headers.origin;
71
93
  if (!(origins.has(origin) || (origin === undefined && hosts.has(req.headers.host)))) return sendJson(res, 403, { error: "forbidden origin" });