@jxsuite/server 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "type": "module",
15
15
  "exports": {
16
- ".": "./src/server.js"
16
+ ".": "./src/server.js",
17
+ "./claude-session": "./src/claude-session.js"
17
18
  },
18
19
  "publishConfig": {
19
20
  "provenance": true
@@ -22,6 +23,12 @@
22
23
  "upgrade": "bunx npm-check-updates -u && bun install"
23
24
  },
24
25
  "dependencies": {
25
- "chokidar": "^5.0.0"
26
+ "@anthropic-ai/claude-agent-sdk": "^0.3.152",
27
+ "@anthropic-ai/sdk": "^0.99.0",
28
+ "@jxsuite/create": "^0.19.0",
29
+ "@jxsuite/parser": "^0.20.0",
30
+ "@modelcontextprotocol/sdk": "^1.29.0",
31
+ "chokidar": "^5.0.0",
32
+ "zod": "^4.4.3"
26
33
  }
27
34
  }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Claude-session.js — Claude Agent SDK session manager
3
+ *
4
+ * Manages Claude Code sessions via @anthropic-ai/claude-agent-sdk, providing: - Session creation
5
+ * with streaming output - Follow-up messages via session resume - SSE broadcasting to connected
6
+ * frontend clients - Session interruption and cleanup
7
+ */
8
+
9
+ import { query } from "@anthropic-ai/claude-agent-sdk";
10
+
11
+ /** @typedef {import("@anthropic-ai/claude-agent-sdk").SDKMessage} SDKMessage */
12
+ /** @typedef {import("@anthropic-ai/claude-agent-sdk").Options} AgentOptions */
13
+
14
+ /**
15
+ * Build a clean env for SDK child processes. Strips session-specific vars from a parent Claude Code
16
+ * instance that would override the SDK's own credential resolution (OAuth from ~/.claude/).
17
+ */
18
+ function buildSdkEnv() {
19
+ const env = { ...process.env };
20
+ delete env.ANTHROPIC_AUTH_TOKEN;
21
+ delete env.ANTHROPIC_BASE_URL;
22
+ delete env.CLAUDE_CODE_ENTRYPOINT;
23
+ delete env.CLAUDE_CODE_EXECPATH;
24
+ delete env.CLAUDE_AGENT_SDK_VERSION;
25
+ delete env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
26
+ delete env.CLAUDECODE;
27
+ env.CLAUDE_AGENT_SDK_CLIENT_APP = "jx-studio/0.17.0";
28
+ return env;
29
+ }
30
+
31
+ /**
32
+ * @typedef {{
33
+ * id: string;
34
+ * sessionId: string | null;
35
+ * projectRoot: string;
36
+ * messages: SDKMessage[];
37
+ * sseClients: Set<(event: string, data: unknown) => void>;
38
+ * status: "active" | "idle" | "stopped";
39
+ * abortController: AbortController | null;
40
+ * query: AsyncGenerator | null;
41
+ * }} Session
42
+ */
43
+
44
+ /** @type {Map<string, Session>} */
45
+ const sessions = new Map();
46
+
47
+ let idCounter = 0;
48
+
49
+ function genId() {
50
+ return `ai_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
51
+ }
52
+
53
+ /**
54
+ * Broadcast an SSE event to all connected clients for a session.
55
+ *
56
+ * @param {Session} session
57
+ * @param {string} event
58
+ * @param {unknown} data
59
+ */
60
+ function broadcast(session, event, data) {
61
+ session.messages.push(/** @type {any} */ (data));
62
+ for (const send of session.sseClients) {
63
+ send(event, data);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Process the async message stream from a query.
69
+ *
70
+ * @param {Session} session
71
+ * @param {AsyncGenerator<SDKMessage>} stream
72
+ */
73
+ async function processStream(session, stream) {
74
+ session.status = "active";
75
+ try {
76
+ for await (const message of stream) {
77
+ if (/** @type {string} */ (session.status) === "stopped") break;
78
+ if (message.session_id && !session.sessionId) {
79
+ session.sessionId = message.session_id;
80
+ }
81
+ broadcast(session, message.type, message);
82
+ }
83
+ } catch (err) {
84
+ if (/** @type {any} */ (err).name !== "AbortError") {
85
+ broadcast(session, "error", { type: "error", error: String(err) });
86
+ }
87
+ } finally {
88
+ session.status = "idle";
89
+ session.query = null;
90
+ session.abortController = null;
91
+ broadcast(session, "done", { type: "done" });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create a new AI session and begin streaming.
97
+ *
98
+ * @param {string} projectRoot
99
+ * @param {string} message
100
+ * @param {{ systemPrompt?: string }} [opts]
101
+ * @returns {{ id: string }}
102
+ */
103
+ export function createSession(projectRoot, message, opts = {}) {
104
+ const id = genId();
105
+ const abortController = new AbortController();
106
+
107
+ /** @type {Session} */
108
+ const session = {
109
+ id,
110
+ sessionId: null,
111
+ projectRoot,
112
+ messages: [],
113
+ sseClients: new Set(),
114
+ status: "active",
115
+ abortController,
116
+ query: null,
117
+ };
118
+
119
+ sessions.set(id, session);
120
+
121
+ /** @type {AgentOptions} */
122
+ const queryOpts = {
123
+ cwd: projectRoot,
124
+ abortController,
125
+ permissionMode: "acceptEdits",
126
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
127
+ maxTurns: 30,
128
+ includePartialMessages: true,
129
+ persistSession: true,
130
+ env: buildSdkEnv(),
131
+ };
132
+
133
+ if (opts.systemPrompt) {
134
+ queryOpts.systemPrompt = opts.systemPrompt;
135
+ }
136
+
137
+ const stream = query({ prompt: message, options: queryOpts });
138
+ session.query = stream;
139
+ processStream(session, stream);
140
+
141
+ return { id };
142
+ }
143
+
144
+ /**
145
+ * Send a follow-up message to an existing session.
146
+ *
147
+ * @param {string} id
148
+ * @param {string} message
149
+ */
150
+ export function sendMessage(id, message) {
151
+ const session = sessions.get(id);
152
+ if (!session) throw new Error(`Session not found: ${id}`);
153
+ if (session.status === "active") throw new Error("Session is still processing");
154
+
155
+ const abortController = new AbortController();
156
+ session.abortController = abortController;
157
+
158
+ /** @type {AgentOptions} */
159
+ const queryOpts = {
160
+ cwd: session.projectRoot,
161
+ abortController,
162
+ permissionMode: "acceptEdits",
163
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
164
+ maxTurns: 30,
165
+ includePartialMessages: true,
166
+ continue: true,
167
+ env: buildSdkEnv(),
168
+ };
169
+
170
+ if (session.sessionId) {
171
+ queryOpts.resume = session.sessionId;
172
+ delete queryOpts.continue;
173
+ }
174
+
175
+ const stream = query({ prompt: message, options: queryOpts });
176
+ session.query = stream;
177
+ processStream(session, stream);
178
+ }
179
+
180
+ /**
181
+ * Stop a running session (interrupt the current query).
182
+ *
183
+ * @param {string} id
184
+ */
185
+ export function stopSession(id) {
186
+ const session = sessions.get(id);
187
+ if (!session) return;
188
+ session.status = "stopped";
189
+ if (session.abortController) {
190
+ session.abortController.abort();
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Remove a session entirely.
196
+ *
197
+ * @param {string} id
198
+ */
199
+ export function deleteSession(id) {
200
+ stopSession(id);
201
+ sessions.delete(id);
202
+ }
203
+
204
+ /**
205
+ * Get an SSE Response stream for a session.
206
+ *
207
+ * @param {string} id
208
+ * @returns {Response}
209
+ */
210
+ export function streamSession(id) {
211
+ const session = sessions.get(id);
212
+ if (!session) {
213
+ return new Response("Session not found", { status: 404 });
214
+ }
215
+
216
+ const encoder = new TextEncoder();
217
+ /** @type {(event: string, data: unknown) => void} */
218
+ let sendRef = () => {};
219
+ const stream = new ReadableStream({
220
+ start(controller) {
221
+ /** @param {string} event @param {unknown} data */
222
+ sendRef = (event, data) => {
223
+ try {
224
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
225
+ } catch {
226
+ // Stream closed
227
+ }
228
+ };
229
+
230
+ session.sseClients.add(sendRef);
231
+
232
+ for (const msg of session.messages) {
233
+ sendRef(/** @type {any} */ (msg).type || "message", msg);
234
+ }
235
+
236
+ const heartbeat = setInterval(() => {
237
+ try {
238
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
239
+ } catch {
240
+ clearInterval(heartbeat);
241
+ session.sseClients.delete(sendRef);
242
+ }
243
+ }, 15000);
244
+ },
245
+ cancel() {
246
+ session.sseClients.delete(sendRef);
247
+ },
248
+ });
249
+
250
+ return new Response(stream, {
251
+ headers: {
252
+ "Content-Type": "text/event-stream",
253
+ "Cache-Control": "no-cache",
254
+ Connection: "keep-alive",
255
+ },
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Check if the claude agent SDK can authenticate.
261
+ *
262
+ * @returns {Promise<{ authenticated: boolean; error?: string }>}
263
+ */
264
+ export async function getAuthStatus() {
265
+ try {
266
+ const ctrl = new AbortController();
267
+ const timeout = setTimeout(() => ctrl.abort(), 15000);
268
+ const testStream = query({
269
+ prompt: "Say OK",
270
+ options: {
271
+ abortController: ctrl,
272
+ maxTurns: 1,
273
+ persistSession: false,
274
+ includePartialMessages: true,
275
+ env: buildSdkEnv(),
276
+ },
277
+ });
278
+
279
+ for await (const msg of testStream) {
280
+ if (msg.type === "assistant") {
281
+ const content = /** @type {any} */ (msg).message?.content;
282
+ if (Array.isArray(content)) {
283
+ const text = content.find((b) => b.type === "text");
284
+ if (text?.text?.toLowerCase().includes("error")) {
285
+ clearTimeout(timeout);
286
+ ctrl.abort();
287
+ return { authenticated: false, error: text.text };
288
+ }
289
+ }
290
+ clearTimeout(timeout);
291
+ ctrl.abort();
292
+ return { authenticated: true };
293
+ }
294
+ if (msg.type === "result") {
295
+ const result = /** @type {any} */ (msg);
296
+ clearTimeout(timeout);
297
+ ctrl.abort();
298
+ if (result.is_error) {
299
+ return { authenticated: false, error: result.result || "API error" };
300
+ }
301
+ return { authenticated: true };
302
+ }
303
+ }
304
+
305
+ clearTimeout(timeout);
306
+ return { authenticated: true };
307
+ } catch (err) {
308
+ return { authenticated: false, error: String(err) };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Get session info.
314
+ *
315
+ * @param {string} id
316
+ */
317
+ export function getSession(id) {
318
+ const session = sessions.get(id);
319
+ if (!session) return null;
320
+ return { id: session.id, status: session.status, messageCount: session.messages.length };
321
+ }
package/src/code-api.js CHANGED
@@ -10,6 +10,15 @@ import { join, resolve } from "node:path";
10
10
  import { unlink } from "node:fs/promises";
11
11
  import { format } from "oxfmt";
12
12
 
13
+ /**
14
+ * @typedef {{
15
+ * span: { offset: number; line: number; [key: string]: unknown };
16
+ * [key: string]: unknown;
17
+ * }} OxcLabel
18
+ *
19
+ * @typedef {{ labels?: OxcLabel[]; [key: string]: unknown }} OxcDiagnostic
20
+ */
21
+
13
22
  const OXLINT_BIN = resolve(
14
23
  import.meta.dir,
15
24
  "../../node_modules/.bin",
@@ -40,7 +49,7 @@ function unwrapFormatted(formatted) {
40
49
  }
41
50
 
42
51
  /**
43
- * @param {any[]} diagnostics
52
+ * @param {OxcDiagnostic[]} diagnostics
44
53
  * @param {number} headerLen
45
54
  */
46
55
  function adjustDiagnostics(diagnostics, headerLen) {
@@ -51,7 +60,7 @@ function adjustDiagnostics(diagnostics, headerLen) {
51
60
  })
52
61
  .map((d) => ({
53
62
  ...d,
54
- labels: d.labels.map((/** @type {any} */ label) => ({
63
+ labels: d.labels?.map((label) => ({
55
64
  ...label,
56
65
  span: {
57
66
  ...label.span,
@@ -98,8 +107,11 @@ export async function handleCodeApi(req, url) {
98
107
  code: unwrapFormatted(result.code),
99
108
  errors: result.errors,
100
109
  });
101
- } catch (/** @type {any} */ e) {
102
- return Response.json({ code, errors: [{ message: e.message }] });
110
+ } catch (/** @type {unknown} */ e) {
111
+ return Response.json({
112
+ code,
113
+ errors: [{ message: /** @type {{ message?: string }} */ (e).message }],
114
+ });
103
115
  }
104
116
  }
105
117
 
@@ -112,8 +124,8 @@ export async function handleCodeApi(req, url) {
112
124
  try {
113
125
  const minified = minifier.transformSync(code).trim();
114
126
  return Response.json({ code: minified });
115
- } catch (/** @type {any} */ e) {
116
- return Response.json({ code, error: e.message });
127
+ } catch (/** @type {unknown} */ e) {
128
+ return Response.json({ code, error: /** @type {{ message?: string }} */ (e).message });
117
129
  }
118
130
  }
119
131
 
@@ -142,8 +154,11 @@ export async function handleCodeApi(req, url) {
142
154
  const parsed = JSON.parse(output);
143
155
  const adjusted = adjustDiagnostics(parsed.diagnostics || [], headerLen);
144
156
  return Response.json({ diagnostics: adjusted });
145
- } catch (/** @type {any} */ e) {
146
- return Response.json({ diagnostics: [], error: e.message });
157
+ } catch (/** @type {unknown} */ e) {
158
+ return Response.json({
159
+ diagnostics: [],
160
+ error: /** @type {{ message?: string }} */ (e).message,
161
+ });
147
162
  } finally {
148
163
  try {
149
164
  await unlink(tmpFile);
package/src/resolve.js CHANGED
@@ -29,8 +29,11 @@ export async function handleResolve(req, root) {
29
29
  } else {
30
30
  moduleAbsPath = resolve(root, $src);
31
31
  }
32
- } catch (/** @type {any} */ e) {
33
- return new Response(`Cannot resolve $src "${$src}": ${e.message}`, { status: 400 });
32
+ } catch (/** @type {unknown} */ e) {
33
+ return new Response(
34
+ `Cannot resolve $src "${$src}": ${/** @type {{ message?: string }} */ (e).message}`,
35
+ { status: 400 },
36
+ );
34
37
  }
35
38
 
36
39
  // Rebase relative config paths from doc-relative to CWD-relative
@@ -74,7 +77,9 @@ export async function handleResolve(req, root) {
74
77
 
75
78
  // Self-contained: construct class from schema
76
79
  const DynClass = classFromSchema(classDef);
77
- const instance = /** @type {any} */ (new DynClass(config));
80
+ const instance = /** @type {{ resolve?: () => unknown; value?: unknown }} */ (
81
+ new DynClass(config)
82
+ );
78
83
  const value =
79
84
  typeof instance.resolve === "function"
80
85
  ? await instance.resolve()
@@ -82,8 +87,11 @@ export async function handleResolve(req, root) {
82
87
  ? instance.value
83
88
  : instance;
84
89
  return Response.json(value);
85
- } catch (/** @type {any} */ e) {
86
- return Response.json({ error: e.message }, { status: 500 });
90
+ } catch (/** @type {unknown} */ e) {
91
+ return Response.json(
92
+ { error: /** @type {{ message?: string }} */ (e).message },
93
+ { status: 500 },
94
+ );
87
95
  }
88
96
  }
89
97
 
@@ -122,15 +130,20 @@ export async function handleServerFunction(req, root) {
122
130
  } else {
123
131
  moduleAbsPath = resolve(root, $src);
124
132
  }
125
- } catch (/** @type {any} */ e) {
126
- return new Response(`Cannot resolve $src: ${e.message}`, { status: 400 });
133
+ } catch (/** @type {unknown} */ e) {
134
+ return new Response(`Cannot resolve $src: ${/** @type {{ message?: string }} */ (e).message}`, {
135
+ status: 400,
136
+ });
127
137
  }
128
138
 
129
139
  let mod;
130
140
  try {
131
141
  mod = await import(moduleAbsPath);
132
- } catch (/** @type {any} */ e) {
133
- return new Response(`Failed to import "${$src}": ${e.message}`, { status: 500 });
142
+ } catch (/** @type {unknown} */ e) {
143
+ return new Response(
144
+ `Failed to import "${$src}": ${/** @type {{ message?: string }} */ (e).message}`,
145
+ { status: 500 },
146
+ );
134
147
  }
135
148
 
136
149
  const fn = mod[xport] ?? mod.default?.[xport];
@@ -141,8 +154,11 @@ export async function handleServerFunction(req, root) {
141
154
  try {
142
155
  const result = await fn(args);
143
156
  return Response.json(result ?? null);
144
- } catch (/** @type {any} */ e) {
145
- return Response.json({ error: e.message }, { status: 500 });
157
+ } catch (/** @type {unknown} */ e) {
158
+ return Response.json(
159
+ { error: /** @type {{ message?: string }} */ (e).message },
160
+ { status: 500 },
161
+ );
146
162
  }
147
163
  }
148
164
 
@@ -150,7 +166,7 @@ export async function handleServerFunction(req, root) {
150
166
  * Dynamically construct a class from a .class.json schema definition. Server-side variant — no
151
167
  * private field limitations.
152
168
  *
153
- * @param {any} classDef
169
+ * @param {ClassJsonDef} classDef
154
170
  */
155
171
  function classFromSchema(classDef) {
156
172
  const fields = classDef.$defs?.fields ?? {};
@@ -159,8 +175,8 @@ function classFromSchema(classDef) {
159
175
 
160
176
  class DynClass {
161
177
  constructor(config = {}) {
162
- const self = /** @type {any} */ (this);
163
- const cfg = /** @type {Record<string, any>} */ (config);
178
+ const self = /** @type {Record<string, unknown>} */ (this);
179
+ const cfg = /** @type {Record<string, unknown>} */ (config);
164
180
  for (const [key, field] of Object.entries(fields)) {
165
181
  const id = field.identifier ?? key;
166
182
  if (cfg[id] !== undefined) self[id] = cfg[id];
@@ -177,27 +193,28 @@ function classFromSchema(classDef) {
177
193
 
178
194
  for (const [key, method] of Object.entries(methods)) {
179
195
  const name = method.identifier ?? key;
180
- const params = (method.parameters ?? []).map((/** @type {any} */ p) => {
181
- if (p.$ref) return p.$ref.split("/").pop();
196
+ const params = (method.parameters ?? []).map((p) => {
197
+ if (p.$ref) return p.$ref.split("/").pop() ?? "arg";
182
198
  return p.identifier ?? p.name ?? "arg";
183
199
  });
184
200
  const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
185
201
 
186
202
  if (method.role === "accessor") {
187
- /** @type {any} */
203
+ /** @type {PropertyDescriptor} */
188
204
  const descriptor = {};
189
- if (method.getter) descriptor.get = new Function(method.getter.body);
205
+ if (method.getter)
206
+ descriptor.get = /** @type {() => unknown} */ (new Function(method.getter.body));
190
207
  if (method.setter) {
191
- const sp = (method.setter.parameters ?? []).map(
192
- (/** @type {any} */ p) => p.$ref?.split("/").pop() ?? "v",
208
+ const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
209
+ descriptor.set = /** @type {(v: unknown) => void} */ (
210
+ new Function(...sp, method.setter.body)
193
211
  );
194
- descriptor.set = new Function(...sp, method.setter.body);
195
212
  }
196
213
  Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
197
214
  } else if (method.scope === "static") {
198
- /** @type {any} */ (DynClass)[name] = new Function(...params, bodyStr);
215
+ /** @type {DynamicClass} */ (DynClass)[name] = new Function(...params, bodyStr);
199
216
  } else {
200
- /** @type {any} */ (DynClass.prototype)[name] = new Function(...params, bodyStr);
217
+ /** @type {DynamicClass} */ (DynClass).prototype[name] = new Function(...params, bodyStr);
201
218
  }
202
219
  }
203
220
 
package/src/server.js CHANGED
@@ -270,7 +270,7 @@ export async function createDevServer(options) {
270
270
  if (result.success && result.outputs.length > 0) {
271
271
  bundleCache.set(cacheKey, await result.outputs[0].text());
272
272
  }
273
- } catch (/** @type {any} */ e) {
273
+ } catch (/** @type {unknown} */ e) {
274
274
  console.error("Bundle failed for", resolved, e);
275
275
  }
276
276
  }
package/src/studio-api.js CHANGED
@@ -11,6 +11,7 @@ import { resolve, relative, basename, dirname, isAbsolute } from "node:path";
11
11
  import { readdir, stat, readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
12
12
  import { readFileSync, existsSync } from "node:fs";
13
13
  import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
14
+ import * as claude from "./claude-session.js";
14
15
 
15
16
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
16
17
  const fwd = (/** @type {string} */ p) => p.replaceAll("\\", "/");
@@ -40,6 +41,14 @@ const statusMap = { M: "M", T: "T", A: "A", D: "D", R: "R", C: "C", U: "U" };
40
41
  * Parse raw `git status --porcelain=v2 --branch` output into structured data.
41
42
  *
42
43
  * @param {string} out
44
+ * @returns {{
45
+ * branch: string;
46
+ * ahead: number;
47
+ * behind: number;
48
+ * files: { path: string; status: string; staged: boolean }[];
49
+ * isRepo?: boolean;
50
+ * remotes?: string[];
51
+ * }}
43
52
  */
44
53
  export function parseGitStatus(out) {
45
54
  let branch = "";
@@ -97,6 +106,7 @@ export function parseGitStatus(out) {
97
106
  * @param {URL} url
98
107
  * @param {string} root
99
108
  * @param {string | null} [activeProjectRoot]
109
+ * @returns {Promise<Response | null>}
100
110
  */
101
111
  export async function handleStudioApi(req, url, root, activeProjectRoot = null) {
102
112
  const path = url.pathname;
@@ -121,8 +131,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
121
131
  const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
122
132
  try {
123
133
  assertAccessible(absDir, root, activeProjectRoot);
124
- } catch (/** @type {any} */ e) {
125
- return Response.json({ error: e.message }, { status: 400 });
134
+ } catch (/** @type {unknown} */ e) {
135
+ return Response.json(
136
+ { error: /** @type {{ message?: string }} */ (e).message },
137
+ { status: 400 },
138
+ );
126
139
  }
127
140
  try {
128
141
  const projectRoot = fwd(absDir);
@@ -154,8 +167,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
154
167
  } catch {}
155
168
 
156
169
  return Response.json({ isSiteProject, projectConfig, directories, projectRoot });
157
- } catch (/** @type {any} */ e) {
158
- return Response.json({ error: e.message }, { status: 500 });
170
+ } catch (/** @type {unknown} */ e) {
171
+ return Response.json(
172
+ { error: /** @type {{ message?: string }} */ (e).message },
173
+ { status: 500 },
174
+ );
159
175
  }
160
176
  }
161
177
 
@@ -189,8 +205,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
189
205
  dir = parent;
190
206
  }
191
207
  return Response.json({ sitePath: null });
192
- } catch (/** @type {any} */ e) {
193
- return Response.json({ error: e.message }, { status: 500 });
208
+ } catch (/** @type {unknown} */ e) {
209
+ return Response.json(
210
+ { error: /** @type {{ message?: string }} */ (e).message },
211
+ { status: 500 },
212
+ );
194
213
  }
195
214
  }
196
215
 
@@ -211,8 +230,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
211
230
  }
212
231
  } catch {}
213
232
  return Response.json({ path: null });
214
- } catch (/** @type {any} */ e) {
215
- return Response.json({ error: e.message }, { status: 500 });
233
+ } catch (/** @type {unknown} */ e) {
234
+ return Response.json(
235
+ { error: /** @type {{ message?: string }} */ (e).message },
236
+ { status: 500 },
237
+ );
216
238
  }
217
239
  }
218
240
 
@@ -238,8 +260,36 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
238
260
  } catch {}
239
261
  }
240
262
  return Response.json(sites);
241
- } catch (/** @type {any} */ e) {
242
- return Response.json({ error: e.message }, { status: 500 });
263
+ } catch (/** @type {unknown} */ e) {
264
+ return Response.json(
265
+ { error: /** @type {{ message?: string }} */ (e).message },
266
+ { status: 500 },
267
+ );
268
+ }
269
+ }
270
+
271
+ // Create a new project
272
+ if (path === "/__studio/create-project" && req.method === "POST") {
273
+ try {
274
+ const body = await req.json();
275
+ const { name, description, url: siteUrl, adapter, directory } = body;
276
+ if (!name || !directory) {
277
+ return Response.json({ error: "name and directory are required" }, { status: 400 });
278
+ }
279
+ const destPath = resolve(root, directory);
280
+ assertAccessible(destPath, root, activeProjectRoot);
281
+
282
+ const { generateProject } = await import("@jxsuite/create/generate");
283
+ await generateProject(destPath, { name, description, url: siteUrl, adapter });
284
+
285
+ const config = JSON.parse(await readFile(resolve(destPath, "project.json"), "utf8"));
286
+ const projectRoot = fwd(relative(root, destPath));
287
+ return Response.json({ root: projectRoot, config });
288
+ } catch (/** @type {unknown} */ e) {
289
+ return Response.json(
290
+ { error: /** @type {{ message?: string }} */ (e).message },
291
+ { status: 500 },
292
+ );
243
293
  }
244
294
  }
245
295
 
@@ -250,8 +300,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
250
300
  const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
251
301
  try {
252
302
  assertAccessible(absDir, root, activeProjectRoot);
253
- } catch (/** @type {any} */ e) {
254
- return Response.json({ error: e.message }, { status: 400 });
303
+ } catch (/** @type {unknown} */ e) {
304
+ return Response.json(
305
+ { error: /** @type {{ message?: string }} */ (e).message },
306
+ { status: 400 },
307
+ );
255
308
  }
256
309
 
257
310
  /** Report a path relative to the active project root (or server root as fallback). */
@@ -299,8 +352,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
299
352
  });
300
353
  }
301
354
  return Response.json(files);
302
- } catch (/** @type {any} */ e) {
303
- return Response.json({ error: e.message }, { status: 500 });
355
+ } catch (/** @type {unknown} */ e) {
356
+ return Response.json(
357
+ { error: /** @type {{ message?: string }} */ (e).message },
358
+ { status: 500 },
359
+ );
304
360
  }
305
361
  }
306
362
 
@@ -310,8 +366,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
310
366
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
311
367
  try {
312
368
  assertAccessible(scanRoot, root, activeProjectRoot);
313
- } catch (/** @type {any} */ e) {
314
- return Response.json({ error: e.message }, { status: 400 });
369
+ } catch (/** @type {unknown} */ e) {
370
+ return Response.json(
371
+ { error: /** @type {{ message?: string }} */ (e).message },
372
+ { status: 400 },
373
+ );
315
374
  }
316
375
  try {
317
376
  const glob = new Bun.Glob("**/*.{json,md}");
@@ -325,7 +384,6 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
325
384
  continue;
326
385
  const fp = resolve(scanRoot, match);
327
386
  try {
328
- /** @type {any} */
329
387
  let content;
330
388
  if (match.endsWith(".md")) {
331
389
  const source = await readFile(fp, "utf8");
@@ -406,14 +464,17 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
406
464
  source: "npm",
407
465
  package: name,
408
466
  description: decl.description || null,
409
- props: (decl.attributes || []).map((/** @type {any} */ a) => ({
410
- name: a.name,
411
- type: a.type?.text,
412
- default: a.default,
413
- description: a.description || null,
414
- })),
467
+ props: (decl.attributes || []).map(
468
+ (/** @type {Record<string, unknown>} */ a) => ({
469
+ name: a.name,
470
+ type: /** @type {Record<string, unknown> | undefined} */ (a.type)?.text,
471
+ default: a.default,
472
+ description: a.description || null,
473
+ }),
474
+ ),
415
475
  members: (decl.members || []).filter(
416
- (/** @type {any} */ m) => m.kind === "field" && m.privacy !== "private",
476
+ (/** @type {Record<string, unknown>} */ m) =>
477
+ m.kind === "field" && m.privacy !== "private",
417
478
  ),
418
479
  slots: decl.slots || [],
419
480
  events: decl.events || [],
@@ -429,8 +490,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
429
490
  } catch {} // skip if no project package.json
430
491
 
431
492
  return Response.json(components);
432
- } catch (/** @type {any} */ e) {
433
- return Response.json({ error: e.message }, { status: 500 });
493
+ } catch (/** @type {unknown} */ e) {
494
+ return Response.json(
495
+ { error: /** @type {{ message?: string }} */ (e).message },
496
+ { status: 500 },
497
+ );
434
498
  }
435
499
  }
436
500
 
@@ -445,7 +509,14 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
445
509
  if (!existsSync(pkgPath)) return Response.json([]);
446
510
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
447
511
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
448
- /** @type {any[]} */
512
+ /**
513
+ * @type {{
514
+ * name: string;
515
+ * version: string;
516
+ * hasCem: boolean;
517
+ * customElementsPath: string | null;
518
+ * }[]}
519
+ */
449
520
  const packages = [];
450
521
  for (const [name, version] of Object.entries(deps)) {
451
522
  const depPkgPath = resolve(scanRoot, "node_modules", ...name.split("/"), "package.json");
@@ -467,8 +538,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
467
538
  } catch {}
468
539
  }
469
540
  return Response.json(packages);
470
- } catch (/** @type {any} */ e) {
471
- return Response.json({ error: e.message }, { status: 500 });
541
+ } catch (/** @type {unknown} */ e) {
542
+ return Response.json(
543
+ { error: /** @type {{ message?: string }} */ (e).message },
544
+ { status: 500 },
545
+ );
472
546
  }
473
547
  }
474
548
 
@@ -493,8 +567,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
493
567
  if (!existsSync(cemPath)) return Response.json({ cem: null });
494
568
  const cem = JSON.parse(await readFile(cemPath, "utf8"));
495
569
  return Response.json({ cem });
496
- } catch (/** @type {any} */ e) {
497
- return Response.json({ error: e.message }, { status: 500 });
570
+ } catch (/** @type {unknown} */ e) {
571
+ return Response.json(
572
+ { error: /** @type {{ message?: string }} */ (e).message },
573
+ { status: 500 },
574
+ );
498
575
  }
499
576
  }
500
577
 
@@ -519,8 +596,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
519
596
  );
520
597
  }
521
598
  return Response.json({ ok: true });
522
- } catch (/** @type {any} */ e) {
523
- return Response.json({ error: e.message }, { status: 500 });
599
+ } catch (/** @type {unknown} */ e) {
600
+ return Response.json(
601
+ { error: /** @type {{ message?: string }} */ (e).message },
602
+ { status: 500 },
603
+ );
524
604
  }
525
605
  }
526
606
 
@@ -543,8 +623,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
543
623
  );
544
624
  }
545
625
  return Response.json({ ok: true });
546
- } catch (/** @type {any} */ e) {
547
- return Response.json({ error: e.message }, { status: 500 });
626
+ } catch (/** @type {unknown} */ e) {
627
+ return Response.json(
628
+ { error: /** @type {{ message?: string }} */ (e).message },
629
+ { status: 500 },
630
+ );
548
631
  }
549
632
  }
550
633
 
@@ -555,18 +638,21 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
555
638
  const abs = fp.startsWith("~") ? fp.replace("~", process.env.HOME || "") : fp;
556
639
  try {
557
640
  assertAccessible(abs, root, activeProjectRoot);
558
- } catch (/** @type {any} */ e) {
559
- return new Response(e.message, { status: 400 });
641
+ } catch (/** @type {unknown} */ e) {
642
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
560
643
  }
561
644
  try {
562
645
  return Response.json({
563
646
  content: await readFile(abs, "utf8"),
564
647
  path: fp,
565
648
  });
566
- } catch (/** @type {any} */ e) {
567
- return e.code === "ENOENT"
649
+ } catch (/** @type {unknown} */ e) {
650
+ return /** @type {{ code?: string }} */ (e).code === "ENOENT"
568
651
  ? new Response("Not found", { status: 404 })
569
- : Response.json({ error: e.message }, { status: 500 });
652
+ : Response.json(
653
+ { error: /** @type {{ message?: string }} */ (e).message },
654
+ { status: 500 },
655
+ );
570
656
  }
571
657
  }
572
658
 
@@ -577,15 +663,18 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
577
663
  const abs = resolve(root, fp);
578
664
  try {
579
665
  assertAccessible(abs, root, activeProjectRoot);
580
- } catch (/** @type {any} */ e) {
581
- return new Response(e.message, { status: 400 });
666
+ } catch (/** @type {unknown} */ e) {
667
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
582
668
  }
583
669
  try {
584
670
  await mkdir(dirname(abs), { recursive: true });
585
671
  await writeFile(abs, await req.text(), "utf8");
586
672
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
587
- } catch (/** @type {any} */ e) {
588
- return Response.json({ error: e.message }, { status: 500 });
673
+ } catch (/** @type {unknown} */ e) {
674
+ return Response.json(
675
+ { error: /** @type {{ message?: string }} */ (e).message },
676
+ { status: 500 },
677
+ );
589
678
  }
590
679
  }
591
680
 
@@ -596,16 +685,19 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
596
685
  const abs = resolve(root, fp);
597
686
  try {
598
687
  assertAccessible(abs, root, activeProjectRoot);
599
- } catch (/** @type {any} */ e) {
600
- return new Response(e.message, { status: 400 });
688
+ } catch (/** @type {unknown} */ e) {
689
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
601
690
  }
602
691
  try {
603
692
  await mkdir(dirname(abs), { recursive: true });
604
693
  const buffer = await req.arrayBuffer();
605
694
  await Bun.write(abs, new Uint8Array(buffer));
606
695
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
607
- } catch (/** @type {any} */ e) {
608
- return Response.json({ error: e.message }, { status: 500 });
696
+ } catch (/** @type {unknown} */ e) {
697
+ return Response.json(
698
+ { error: /** @type {{ message?: string }} */ (e).message },
699
+ { status: 500 },
700
+ );
609
701
  }
610
702
  }
611
703
 
@@ -616,16 +708,19 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
616
708
  const abs = resolve(root, fp);
617
709
  try {
618
710
  assertAccessible(abs, root, activeProjectRoot);
619
- } catch (/** @type {any} */ e) {
620
- return new Response(e.message, { status: 400 });
711
+ } catch (/** @type {unknown} */ e) {
712
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
621
713
  }
622
714
  try {
623
715
  await unlink(abs);
624
716
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
625
- } catch (/** @type {any} */ e) {
626
- return e.code === "ENOENT"
717
+ } catch (/** @type {unknown} */ e) {
718
+ return /** @type {{ code?: string }} */ (e).code === "ENOENT"
627
719
  ? new Response("Not found", { status: 404 })
628
- : Response.json({ error: e.message }, { status: 500 });
720
+ : Response.json(
721
+ { error: /** @type {{ message?: string }} */ (e).message },
722
+ { status: 500 },
723
+ );
629
724
  }
630
725
  }
631
726
 
@@ -644,8 +739,8 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
644
739
  try {
645
740
  assertAccessible(absFrom, root, activeProjectRoot);
646
741
  assertAccessible(absTo, root, activeProjectRoot);
647
- } catch (/** @type {any} */ e) {
648
- return new Response(e.message, { status: 400 });
742
+ } catch (/** @type {unknown} */ e) {
743
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
649
744
  }
650
745
  try {
651
746
  await mkdir(dirname(absTo), { recursive: true });
@@ -655,8 +750,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
655
750
  from: fwd(relative(root, absFrom)),
656
751
  to: fwd(relative(root, absTo)),
657
752
  });
658
- } catch (/** @type {any} */ e) {
659
- return Response.json({ error: e.message }, { status: 500 });
753
+ } catch (/** @type {unknown} */ e) {
754
+ return Response.json(
755
+ { error: /** @type {{ message?: string }} */ (e).message },
756
+ { status: 500 },
757
+ );
660
758
  }
661
759
  }
662
760
 
@@ -684,8 +782,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
684
782
  path: matches[0],
685
783
  ...(matches.length > 1 ? { alternatives: matches } : {}),
686
784
  });
687
- } catch (/** @type {any} */ e) {
688
- return Response.json({ error: e.message }, { status: 500 });
785
+ } catch (/** @type {unknown} */ e) {
786
+ return Response.json(
787
+ { error: /** @type {{ message?: string }} */ (e).message },
788
+ { status: 500 },
789
+ );
689
790
  }
690
791
  }
691
792
 
@@ -705,8 +806,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
705
806
  } else {
706
807
  moduleAbsPath = resolve(root, src);
707
808
  }
708
- } catch (/** @type {any} */ e) {
709
- return Response.json({ schema: null, error: e.message });
809
+ } catch (/** @type {unknown} */ e) {
810
+ return Response.json({
811
+ schema: null,
812
+ error: /** @type {{ message?: string }} */ (e).message,
813
+ });
710
814
  }
711
815
 
712
816
  // .class.json: read and extract schema directly
@@ -715,8 +819,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
715
819
  const content = readFileSync(moduleAbsPath, "utf8");
716
820
  const classDef = JSON.parse(content);
717
821
  return Response.json({ schema: extractStudioSchema(classDef, moduleAbsPath) });
718
- } catch (/** @type {any} */ e) {
719
- return Response.json({ schema: null, error: e.message });
822
+ } catch (/** @type {unknown} */ e) {
823
+ return Response.json({
824
+ schema: null,
825
+ error: /** @type {{ message?: string }} */ (e).message,
826
+ });
720
827
  }
721
828
  }
722
829
 
@@ -741,8 +848,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
741
848
  return Response.json({ schema: null, error: `Export "${exportName}" not found` });
742
849
  }
743
850
  return Response.json({ schema: ExportedClass.schema ?? null });
744
- } catch (/** @type {any} */ e) {
745
- return Response.json({ schema: null, error: e.message });
851
+ } catch (/** @type {unknown} */ e) {
852
+ return Response.json({
853
+ schema: null,
854
+ error: /** @type {{ message?: string }} */ (e).message,
855
+ });
746
856
  }
747
857
  }
748
858
 
@@ -763,8 +873,44 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
763
873
 
764
874
  try {
765
875
  if (gitCmd === "status" && req.method === "GET") {
766
- const out = await runGit(["status", "--porcelain=v2", "--branch"]);
767
- return Response.json(parseGitStatus(out));
876
+ // Check if we're in a git repo first
877
+ const checkProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
878
+ cwd,
879
+ stdout: "pipe",
880
+ stderr: "pipe",
881
+ });
882
+ const checkExit = await checkProc.exited;
883
+ if (checkExit !== 0) {
884
+ return Response.json({
885
+ isRepo: false,
886
+ branch: "",
887
+ files: [],
888
+ ahead: 0,
889
+ behind: 0,
890
+ remotes: [],
891
+ });
892
+ }
893
+
894
+ const [out, remotesOut] = await Promise.all([
895
+ runGit(["status", "--porcelain=v2", "--branch"]),
896
+ runGit(["remote"]),
897
+ ]);
898
+ const status = parseGitStatus(out);
899
+ status.isRepo = true;
900
+ status.remotes = remotesOut.trim().split("\n").filter(Boolean);
901
+ return Response.json(status);
902
+ }
903
+
904
+ if (gitCmd === "init" && req.method === "POST") {
905
+ await runGit(["init"]);
906
+ return Response.json({ ok: true });
907
+ }
908
+
909
+ if (gitCmd === "add-remote" && req.method === "POST") {
910
+ const { name, url: remoteUrl } = await req.json();
911
+ if (!name || !remoteUrl) return new Response("name and url required", { status: 400 });
912
+ await runGit(["remote", "add", name, remoteUrl]);
913
+ return Response.json({ ok: true });
768
914
  }
769
915
 
770
916
  if (gitCmd === "branches" && req.method === "GET") {
@@ -828,7 +974,17 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
828
974
  }
829
975
 
830
976
  if (gitCmd === "push" && req.method === "POST") {
831
- await runGit(["push"]);
977
+ let body = {};
978
+ try {
979
+ body = await req.json();
980
+ } catch {}
981
+ const { setUpstream } = /** @type {{ setUpstream?: boolean }} */ (body);
982
+ if (setUpstream) {
983
+ const branch = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
984
+ await runGit(["push", "-u", "origin", branch]);
985
+ } else {
986
+ await runGit(["push"]);
987
+ }
832
988
  return Response.json({ ok: true });
833
989
  }
834
990
 
@@ -885,11 +1041,94 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
885
1041
  await runGit(["checkout", "--", ...files]);
886
1042
  return Response.json({ ok: true });
887
1043
  }
1044
+
1045
+ if (gitCmd === "clone" && req.method === "POST") {
1046
+ const { url } = await req.json();
1047
+ if (!url || typeof url !== "string")
1048
+ return Response.json({ error: "Missing url" }, { status: 400 });
1049
+ const repoName = basename(url.replace(/\.git$/, ""));
1050
+ const dest = resolve(cwd, repoName);
1051
+ const proc = Bun.spawn(["git", "clone", url, dest], {
1052
+ cwd,
1053
+ stdout: "pipe",
1054
+ stderr: "pipe",
1055
+ });
1056
+ const exitCode = await proc.exited;
1057
+ const stderr = await new Response(proc.stderr).text();
1058
+ if (exitCode !== 0) throw new Error(stderr || `git clone exited with ${exitCode}`);
1059
+ return Response.json({ ok: true, root: dest });
1060
+ }
1061
+ } catch (/** @type {unknown} */ e) {
1062
+ return Response.json(
1063
+ { error: /** @type {{ message?: string }} */ (e).message },
1064
+ { status: 500 },
1065
+ );
1066
+ }
1067
+ }
1068
+
1069
+ // ─── AI Assistant ─────────────────────────────────────────────────────────
1070
+
1071
+ if (path === "/__studio/ai/auth-status" && req.method === "GET") {
1072
+ const status = await claude.getAuthStatus();
1073
+ return Response.json(status);
1074
+ }
1075
+
1076
+ if (path === "/__studio/ai/session" && req.method === "POST") {
1077
+ try {
1078
+ const body = await req.json();
1079
+ const projectDir = activeProjectRoot || root;
1080
+ const result = claude.createSession(projectDir, body.message, {
1081
+ systemPrompt: body.systemPrompt,
1082
+ });
1083
+ return Response.json(result);
1084
+ } catch (/** @type {any} */ e) {
1085
+ return Response.json({ error: e.message }, { status: 500 });
1086
+ }
1087
+ }
1088
+
1089
+ if (
1090
+ path.startsWith("/__studio/ai/session/") &&
1091
+ path.endsWith("/stream") &&
1092
+ req.method === "GET"
1093
+ ) {
1094
+ const id = path.split("/")[4];
1095
+ return claude.streamSession(id);
1096
+ }
1097
+
1098
+ if (
1099
+ path.startsWith("/__studio/ai/session/") &&
1100
+ path.endsWith("/message") &&
1101
+ req.method === "POST"
1102
+ ) {
1103
+ try {
1104
+ const id = path.split("/")[4];
1105
+ const body = await req.json();
1106
+ claude.sendMessage(id, body.message);
1107
+ return Response.json({ ok: true });
888
1108
  } catch (/** @type {any} */ e) {
889
1109
  return Response.json({ error: e.message }, { status: 500 });
890
1110
  }
891
1111
  }
892
1112
 
1113
+ if (path.startsWith("/__studio/ai/session/") && path.endsWith("/stop") && req.method === "POST") {
1114
+ const id = path.split("/")[4];
1115
+ claude.stopSession(id);
1116
+ return Response.json({ ok: true });
1117
+ }
1118
+
1119
+ if (path.startsWith("/__studio/ai/session/") && req.method === "DELETE") {
1120
+ const id = path.split("/")[4];
1121
+ claude.deleteSession(id);
1122
+ return Response.json({ ok: true });
1123
+ }
1124
+
1125
+ if (path.startsWith("/__studio/ai/session/") && req.method === "GET") {
1126
+ const id = path.split("/")[4];
1127
+ const info = claude.getSession(id);
1128
+ if (!info) return Response.json({ error: "Not found" }, { status: 404 });
1129
+ return Response.json(info);
1130
+ }
1131
+
893
1132
  return null;
894
1133
  }
895
1134
 
@@ -898,9 +1137,13 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
898
1137
  * $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
899
1138
  * in the studio already consumes.
900
1139
  *
901
- * @param {any} classDef
1140
+ * @param {ClassJsonDef} classDef
902
1141
  * @param {string} classJsonPath
903
- * @returns {{ description: any; properties: Record<string, any>; required: string[] }}
1142
+ * @returns {{
1143
+ * description: string | undefined;
1144
+ * properties: Record<string, Record<string, unknown>>;
1145
+ * required: string[];
1146
+ * }}
904
1147
  */
905
1148
  function extractStudioSchema(classDef, classJsonPath) {
906
1149
  // If extends.$ref points to a parent, recursively merge
@@ -918,7 +1161,7 @@ function extractStudioSchema(classDef, classJsonPath) {
918
1161
 
919
1162
  const params = classDef.$defs?.parameters ?? {};
920
1163
  const fields = classDef.$defs?.fields ?? {};
921
- /** @type {Record<string, any>} */
1164
+ /** @type {Record<string, Record<string, unknown>>} */
922
1165
  const properties = {};
923
1166
  /** @type {string[]} */
924
1167
  const required = [];
@@ -934,6 +1177,7 @@ function extractStudioSchema(classDef, classJsonPath) {
934
1177
  // Build properties from parameters (constructor config surface)
935
1178
  for (const [key, param] of Object.entries(params)) {
936
1179
  const id = param.identifier ?? key;
1180
+ /** @type {Record<string, unknown>} */
937
1181
  const prop = {};
938
1182
  if (param.type && typeof param.type === "object") Object.assign(prop, param.type);
939
1183
  if (param.description) prop.description = param.description;
@@ -947,6 +1191,7 @@ function extractStudioSchema(classDef, classJsonPath) {
947
1191
  if (field.role !== "field") continue;
948
1192
  if (field.access === "private") continue;
949
1193
  const id = field.identifier ?? key;
1194
+ /** @type {Record<string, unknown>} */
950
1195
  const prop = {};
951
1196
  if (field.type && typeof field.type === "object") Object.assign(prop, field.type);
952
1197
  if (field.description) prop.description = field.description;
package/src/watch.js CHANGED
@@ -55,7 +55,7 @@ export function injectSSE(html) {
55
55
  * Create the file watcher + SSE system.
56
56
  *
57
57
  * @param {string} root - Absolute path to watch
58
- * @param {any[]} builds - Build entries (for selective rebuild)
58
+ * @param {BuildEntry[]} builds - Build entries (for selective rebuild)
59
59
  * @param {{ ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean }} [opts]
60
60
  * @returns {{
61
61
  * broadcast: () => void;
@@ -77,7 +77,7 @@ export function createWatcher(root, builds, opts = {}) {
77
77
  }
78
78
 
79
79
  function handleSSE() {
80
- /** @type {any} */
80
+ /** @type {((msg: string) => void) | undefined} */
81
81
  let send;
82
82
  const stream = new ReadableStream({
83
83
  start(c) {
@@ -96,7 +96,7 @@ export function createWatcher(root, builds, opts = {}) {
96
96
  }, 15_000);
97
97
  },
98
98
  cancel() {
99
- clients.delete(send);
99
+ if (send) clients.delete(send);
100
100
  },
101
101
  });
102
102
  return new Response(stream, {
@@ -108,7 +108,7 @@ export function createWatcher(root, builds, opts = {}) {
108
108
  });
109
109
  }
110
110
 
111
- /** @type {any} */
111
+ /** @type {ReturnType<typeof setTimeout> | null} */
112
112
  let timer = null;
113
113
  const watcher = chokidar.watch(root, {
114
114
  ignored: (watchedPath) => shouldIgnore(watchedPath, ignore),
@@ -123,7 +123,7 @@ export function createWatcher(root, builds, opts = {}) {
123
123
  watcher.on("all", (_, changedPath) => {
124
124
  const filename = relative(root, changedPath);
125
125
  if (!filename || filename.startsWith("..")) return;
126
- clearTimeout(timer);
126
+ clearTimeout(timer ?? undefined);
127
127
  timer = setTimeout(async () => {
128
128
  if (builds.length > 0) {
129
129
  const result = await rebuild(builds, filename);