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.
- package/CHANGELOG.md +87 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +16 -13
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/app-CnDkB5WO.css +1 -0
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +2 -1
- package/src/App.jsx +37 -70
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +6 -5
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/qa/hints-contact.js +12 -0
- package/src/motion3/qa/patch.js +4 -1
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +14 -27
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +45 -69
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
- package/dist/assets/app-Dige7q3X.css +0 -1
- package/dist/assets/app-DxoI941o.js +0 -5144
- package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
- package/dist/assets/task-board-5PKn7ZaA.js +0 -15
- 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,
|
|
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
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
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 =
|
|
236
|
-
const reason = !
|
|
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:
|
|
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
|
-
|
|
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
|
|
251
|
+
await new Promise((resolvePromise) => server?.close(() => resolvePromise()));
|
|
296
252
|
} catch {
|
|
297
|
-
/* never let
|
|
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
|
package/bin/star-prompt.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
88
|
-
if (
|
|
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({
|
|
94
|
+
writeState({ starredAt: promptedAt, starPromptedVersion: version });
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
|
-
writeState({
|
|
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
|
}
|
package/bin/studio-routes.mjs
CHANGED
|
@@ -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
|
}
|
package/bin/telemetry-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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() });
|
package/bin/update-check.mjs
CHANGED
|
@@ -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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/update-install.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/update-routes.mjs
CHANGED
|
@@ -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" });
|