@lifeaitools/clauth 1.16.9 → 1.18.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.
@@ -1,487 +1,594 @@
1
- import crypto from "crypto";
2
- import fs from "fs";
3
- import os from "os";
4
- import path from "path";
5
- import { spawn } from "child_process";
6
-
7
- const DEFAULT_POLL_TIMEOUT = 600_000;
8
- const MAX_POLL_TIMEOUT = 600_000;
9
- const MAX_EVENT_QUEUE = 100;
10
- const MAX_SNIPPET = 2_000;
11
-
12
- const APP_TARGETS = {
13
- studio_test: {
14
- appSlug: "studio_test",
15
- brandSlug: "studio_test",
16
- packageName: "@regen/studio",
17
- command: "pnpm --filter @regen/studio dev",
18
- url: "http://localhost:3011/editor/local-test-target",
19
- },
20
- "studio-test": {
21
- appSlug: "studio_test",
22
- brandSlug: "studio_test",
23
- packageName: "@regen/studio",
24
- command: "pnpm --filter @regen/studio dev",
25
- url: "http://localhost:3011/editor/local-test-target",
26
- },
27
- prt: {
28
- appSlug: "prt",
29
- brandSlug: "prt",
30
- packageName: "@regen/prt-portal",
31
- command: "pnpm --filter @regen/prt-portal dev",
32
- url: "http://localhost:3006",
33
- },
34
- "prt-portal": {
35
- appSlug: "prt",
36
- brandSlug: "prt",
37
- packageName: "@regen/prt-portal",
38
- command: "pnpm --filter @regen/prt-portal dev",
39
- url: "http://localhost:3006",
40
- },
41
- };
42
-
43
- function nowIso() {
44
- return new Date().toISOString();
45
- }
46
-
47
- function safeString(value, max = MAX_SNIPPET) {
48
- if (typeof value !== "string") return value;
49
- return value.length > max ? value.slice(0, max) : value;
50
- }
51
-
52
- function normalizeEvent(session, body) {
53
- const type = body.type || body.mode || "direct_edit";
54
- const id = body.id || `evt_${crypto.randomUUID()}`;
55
- const target = body.target && typeof body.target === "object" ? { ...body.target } : {};
56
- if (typeof target.textSnippet === "string") target.textSnippet = safeString(target.textSnippet, 500);
57
- if (typeof target.outerHTMLSnippet === "string") target.outerHTMLSnippet = safeString(target.outerHTMLSnippet, MAX_SNIPPET);
58
- if (typeof target.outerHTML === "string" && !target.outerHTMLSnippet) {
59
- target.outerHTMLSnippet = safeString(target.outerHTML, MAX_SNIPPET);
60
- delete target.outerHTML;
61
- }
62
-
63
- return {
64
- type,
65
- id,
66
- mode: body.mode || type,
67
- sessionId: session.sessionId,
68
- brandSlug: session.brandSlug,
69
- appSlug: session.appSlug,
70
- target,
71
- reference: body.reference || null,
72
- instruction: safeString(body.instruction || body.prompt || "", 4_000),
73
- createdAt: nowIso(),
74
- };
75
- }
76
-
77
- function readBody(req, maxBytes = 128 * 1024) {
78
- return new Promise((resolve, reject) => {
79
- let data = "";
80
- req.on("data", chunk => {
81
- data += chunk;
82
- if (data.length > maxBytes) reject(new Error("Body too large"));
83
- });
84
- req.on("end", () => {
85
- if (!data.trim()) return resolve({});
86
- try {
87
- resolve(JSON.parse(data));
88
- } catch {
89
- reject(new Error("Invalid JSON"));
90
- }
91
- });
92
- req.on("error", reject);
93
- });
94
- }
95
-
96
- function writeJson(res, status, data, cors) {
97
- res.writeHead(status, { "Content-Type": "application/json", ...cors });
98
- res.end(JSON.stringify(data));
99
- }
100
-
101
- async function isUrlReachable(url) {
102
- try {
103
- const response = await fetch(url, {
104
- method: "GET",
105
- signal: AbortSignal.timeout(1_500),
106
- });
107
- return response.status < 500;
108
- } catch {
109
- return false;
110
- }
111
- }
112
-
113
- function resolveTarget(input = {}) {
114
- const slug = String(input.appSlug || input.brandSlug || "prt").toLowerCase();
115
- const configured = APP_TARGETS[slug];
116
- // No registration required any appSlug proceeds. Sessions without a devUrl
117
- // or devCommand simply have no iframe target; the picker still works via extension.
118
-
119
- const base = configured || {
120
- appSlug: slug,
121
- brandSlug: input.brandSlug || slug,
122
- command: input.devCommand,
123
- url: input.devUrl,
124
- };
125
-
126
- return {
127
- appSlug: input.appSlug || base.appSlug,
128
- brandSlug: input.brandSlug || base.brandSlug || input.appSlug || base.appSlug,
129
- command: input.devCommand || base.command,
130
- url: input.devUrl || base.url,
131
- cwd: input.cwd || input.repoRoot || process.cwd(),
132
- };
133
- }
134
-
135
- function splitCommand(command) {
136
- if (!command || typeof command !== "string") return null;
137
- const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
138
- return parts.map(part => part.replace(/^"|"$/g, ""));
139
- }
140
-
141
- function startDevProcess(target, logFile) {
142
- const parts = splitCommand(target.command);
143
- if (!parts || parts.length === 0) {
144
- return { started: false, error: "missing_command" };
145
- }
146
-
147
- const logDir = path.join(os.tmpdir(), "clauth-studio-debug");
148
- fs.mkdirSync(logDir, { recursive: true });
149
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
150
- const outPath = path.join(logDir, `${target.appSlug}-${stamp}.out.log`);
151
- const errPath = path.join(logDir, `${target.appSlug}-${stamp}.err.log`);
152
- const out = fs.openSync(outPath, "a");
153
- const err = fs.openSync(errPath, "a");
154
-
155
- const proc = spawn(parts[0], parts.slice(1), {
156
- cwd: target.cwd,
157
- env: process.env,
158
- stdio: ["ignore", out, err],
159
- shell: process.platform === "win32",
160
- detached: true,
161
- windowsHide: true,
162
- });
163
- proc.unref();
164
- try {
165
- fs.appendFileSync(logFile, `[${nowIso()}] studio-debug dev start pid=${proc.pid} command=${target.command}\n`);
166
- } catch {}
167
-
168
- return {
169
- started: true,
170
- pid: proc.pid,
171
- command: target.command,
172
- url: target.url,
173
- stdout: outPath,
174
- stderr: errPath,
175
- process: proc,
176
- };
177
- }
178
-
179
- export class StudioDebugSessionStore {
180
- constructor({ port = 52437, logFile = path.join(os.tmpdir(), "clauth-serve.log"), dispatchAgent = null } = {}) {
181
- this.port = port;
182
- this.logFile = logFile;
183
- this.sessions = new Map();
184
- this.dispatchAgent = dispatchAgent;
185
- }
186
-
187
- async start(input = {}) {
188
- const target = resolveTarget(input);
189
- if (target.error) {
190
- return { ok: false, error: target.error, message: target.message, status: "error" };
191
- }
192
-
193
- const shouldLaunch = input.launchDevServer !== false;
194
- const reachable = await isUrlReachable(target.url);
195
- const launch = reachable
196
- ? { started: false, command: target.command, url: target.url, alreadyRunning: true }
197
- : shouldLaunch
198
- ? startDevProcess(target, this.logFile)
199
- : { started: false, command: target.command, url: target.url, skipped: true };
200
-
201
- const sessionId = input.sessionId || `studio-${crypto.randomUUID()}`;
202
- const token = crypto.randomBytes(24).toString("base64url");
203
- const session = {
204
- sessionId,
205
- token,
206
- brandSlug: target.brandSlug,
207
- appSlug: target.appSlug,
208
- repoRoot: input.repoRoot || target.cwd,
209
- cwd: target.cwd,
210
- devCommand: target.command,
211
- devUrl: target.url,
212
- modeDefault: input.modeDefault || "direct_edit",
213
- status: "waiting_for_agent",
214
- createdAt: nowIso(),
215
- updatedAt: nowIso(),
216
- stopped: false,
217
- events: [],
218
- replies: [],
219
- pendingPolls: [],
220
- launch,
221
- };
222
- this.sessions.set(sessionId, session);
223
-
224
- const relayBaseUrl = `http://127.0.0.1:${this.port}/studio/debug/${sessionId}`;
225
- const claudeBaseUrl = `http://127.0.0.1:${this.port}/studio/claude/${sessionId}`;
226
- const pollCommand = `node scripts/studio-debug-poll.mjs --session ${sessionId} --token ${token} --relay ${claudeBaseUrl}`;
227
-
228
- // When dispatchAgent is wired, events dispatch directly — no polling agent needed.
229
- if (this.dispatchAgent) {
230
- session.status = "waiting_for_event";
231
- }
232
-
233
- return {
234
- ok: true,
235
- sessionId,
236
- token,
237
- devUrl: target.url,
238
- relayBaseUrl,
239
- status: session.status,
240
- launch: {
241
- started: !!launch.started,
242
- alreadyRunning: !!launch.alreadyRunning,
243
- command: launch.command,
244
- url: launch.url,
245
- pid: launch.pid || null,
246
- },
247
- pollCommand,
248
- claudeBaseUrl,
249
- };
250
- }
251
-
252
- get(sessionId) {
253
- return this.sessions.get(sessionId) || null;
254
- }
255
-
256
- authenticate(sessionId, token) {
257
- const session = this.get(sessionId);
258
- if (!session) return { error: "not_found" };
259
- if (session.token !== token) return { error: "unauthorized" };
260
- return { session };
261
- }
262
-
263
- submitEvent(sessionId, body) {
264
- const auth = this.authenticate(sessionId, body.token);
265
- if (auth.error) return auth;
266
- const session = auth.session;
267
- const event = normalizeEvent(session, body);
268
-
269
- session.updatedAt = nowIso();
270
- session.status = "event_pending";
271
-
272
- // If a dispatchAgent handler exists, spawn a full headless Claude agent.
273
- if (this.dispatchAgent) {
274
- // Stash images from the event body so the dispatcher can write them to tmp
275
- if (Array.isArray(body.images) && body.images.length > 0) {
276
- session._pendingImages = body.images;
277
- }
278
- const prompt = [
279
- `You are a Studio local-debug agent. Make ONE source edit to the codebase.`,
280
- ``,
281
- `App: ${session.appSlug} (brand: ${session.brandSlug})`,
282
- `Repo: ${session.repoRoot || session.cwd}`,
283
- `Dev URL: ${session.devUrl || "unknown"}`,
284
- ``,
285
- `Edit event:`,
286
- ` Mode: ${event.mode || "direct_edit"}`,
287
- ` Selector: ${event.selector || "unknown"}`,
288
- ` Instruction: ${event.instruction || "Make the requested edit."}`,
289
- event.textSnippet ? ` Current text: "${event.textSnippet}"` : "",
290
- event.textEdit ? ` New text: "${event.textEdit.newText || ""}"` : "",
291
- event.file ? ` File hint: ${event.file}` : "",
292
- event.notes ? ` Notes: ${event.notes}` : "",
293
- ``,
294
- `Find the source file in the repo that renders this element and make the edit.`,
295
- `After editing, output a JSON object: {"status":"done","message":"<what you did>","filesChanged":["<path>"]}`,
296
- ].filter(Boolean).join("\n");
297
-
298
- this.dispatchAgent(event.id, session.token, null, session, prompt)
299
- .then(result => {
300
- let parsed = {};
301
- try { parsed = JSON.parse((result?.stdout || result?.output || "").trim()); } catch {}
302
- const reply = {
303
- eventId: event.id,
304
- status: parsed.status || "done",
305
- message: parsed.message || result?.stdout?.slice(0, 500) || "Agent completed",
306
- filesChanged: parsed.filesChanged || [],
307
- createdAt: nowIso(),
308
- };
309
- session.replies.push(reply);
310
- session.status = "done";
311
- session.updatedAt = nowIso();
312
- })
313
- .catch(() => {
314
- session.replies.push({
315
- eventId: event.id,
316
- status: "error",
317
- message: "Agent dispatch failed",
318
- filesChanged: [],
319
- createdAt: nowIso(),
320
- });
321
- session.status = "error";
322
- session.updatedAt = nowIso();
323
- });
324
-
325
- return { ok: true, eventId: event.id, status: session.status };
326
- }
327
-
328
- // Legacy path: queue for a polling agent
329
- if (session.pendingPolls.length > 0) {
330
- const poll = session.pendingPolls.shift();
331
- poll(event);
332
- } else {
333
- session.events.push(event);
334
- if (session.events.length > MAX_EVENT_QUEUE) session.events.shift();
335
- }
336
- return { ok: true, eventId: event.id, status: session.status };
337
- }
338
-
339
- poll(sessionId, token, timeoutMs = DEFAULT_POLL_TIMEOUT) {
340
- const auth = this.authenticate(sessionId, token);
341
- if (auth.error) return Promise.resolve(auth);
342
- const session = auth.session;
343
- if (session.events.length > 0) {
344
- session.status = "agent_received";
345
- session.updatedAt = nowIso();
346
- return Promise.resolve(session.events.shift());
347
- }
348
- if (session.stopped) return Promise.resolve({ type: "stopped" });
349
-
350
- const timeout = Math.min(Math.max(Number(timeoutMs) || DEFAULT_POLL_TIMEOUT, 1), MAX_POLL_TIMEOUT);
351
- session.status = "waiting_for_event";
352
- session.updatedAt = nowIso();
353
-
354
- return new Promise(resolve => {
355
- const timer = setTimeout(() => {
356
- session.pendingPolls = session.pendingPolls.filter(fn => fn !== finish);
357
- resolve({ type: "timeout" });
358
- }, timeout);
359
- const finish = event => {
360
- clearTimeout(timer);
361
- session.status = "agent_received";
362
- session.updatedAt = nowIso();
363
- resolve(event);
364
- };
365
- session.pendingPolls.push(finish);
366
- });
367
- }
368
-
369
- reply(sessionId, body) {
370
- const auth = this.authenticate(sessionId, body.token);
371
- if (auth.error) return auth;
372
- const session = auth.session;
373
- const reply = {
374
- eventId: body.eventId,
375
- status: body.status || body.type || "done",
376
- message: body.message || "",
377
- filesChanged: Array.isArray(body.filesChanged)
378
- ? body.filesChanged
379
- : body.file
380
- ? [body.file]
381
- : [],
382
- createdAt: nowIso(),
383
- };
384
- session.replies.push(reply);
385
- session.status = reply.status === "done" ? "done" : reply.status;
386
- session.updatedAt = nowIso();
387
- return { ok: true, status: session.status, reply };
388
- }
389
-
390
- status(sessionId, token) {
391
- const auth = this.authenticate(sessionId, token);
392
- if (auth.error) return auth;
393
- const session = auth.session;
394
- return {
395
- ok: true,
396
- sessionId: session.sessionId,
397
- brandSlug: session.brandSlug,
398
- appSlug: session.appSlug,
399
- devUrl: session.devUrl,
400
- status: session.status,
401
- createdAt: session.createdAt,
402
- updatedAt: session.updatedAt,
403
- pendingEvents: session.events.length,
404
- pendingPolls: session.pendingPolls.length,
405
- replies: session.replies,
406
- launch: {
407
- started: !!session.launch?.started,
408
- alreadyRunning: !!session.launch?.alreadyRunning,
409
- command: session.launch?.command || session.devCommand,
410
- url: session.launch?.url || session.devUrl,
411
- pid: session.launch?.pid || null,
412
- },
413
- };
414
- }
415
-
416
- stop(sessionId, token) {
417
- const auth = this.authenticate(sessionId, token);
418
- if (auth.error) return auth;
419
- const session = auth.session;
420
- session.stopped = true;
421
- session.status = "stopped";
422
- session.updatedAt = nowIso();
423
- if (session.launch?.process && !session.launch.process.killed) {
424
- try {
425
- session.launch.process.kill();
426
- } catch {}
427
- }
428
- for (const poll of session.pendingPolls.splice(0)) poll({ type: "stopped" });
429
- this.sessions.delete(sessionId);
430
- return { ok: true, status: "stopped", sessionId };
431
- }
432
- }
433
-
434
- export function createStudioDebugRuntime(options) {
435
- const store = new StudioDebugSessionStore(options);
436
-
437
- async function handle(req, res, url, cors) {
438
- const reqPath = url.pathname;
439
- const method = req.method;
440
-
441
- if (method === "POST" && reqPath === "/studio/debug/start") {
442
- try {
443
- const body = await readBody(req);
444
- const result = await store.start(body);
445
- return writeJson(res, result.ok ? 200 : 400, result, cors);
446
- } catch (err) {
447
- return writeJson(res, 400, { ok: false, error: err.message }, cors);
448
- }
449
- }
450
-
451
- const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|status|stop)$/);
452
- const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
453
- if (!debugMatch && !claudeMatch) return false;
454
- const [, sessionId, action] = debugMatch || claudeMatch;
455
-
456
- try {
457
- if (method === "POST" && action === "events") {
458
- const result = store.submitEvent(sessionId, await readBody(req));
459
- return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
460
- }
461
- if (method === "GET" && action === "poll") {
462
- const result = await store.poll(sessionId, url.searchParams.get("token"), url.searchParams.get("timeout"));
463
- return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
464
- }
465
- if (method === "POST" && action === "reply") {
466
- const result = store.reply(sessionId, await readBody(req));
467
- return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
468
- }
469
- if (method === "GET" && action === "status") {
470
- const result = store.status(sessionId, url.searchParams.get("token"));
471
- return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
472
- }
473
- if (method === "POST" && action === "stop") {
474
- const body = await readBody(req);
475
- const result = store.stop(sessionId, body.token || url.searchParams.get("token"));
476
- return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
477
- }
478
- return writeJson(res, 405, { ok: false, error: "method_not_allowed" }, cors);
479
- } catch (err) {
480
- return writeJson(res, 400, { ok: false, error: err.message }, cors);
481
- }
482
- }
483
-
484
- return { store, handle };
485
- }
486
-
487
- export const studioDebugTargets = APP_TARGETS;
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { spawn } from "child_process";
6
+
7
+ const DEFAULT_POLL_TIMEOUT = 600_000;
8
+ const MAX_POLL_TIMEOUT = 600_000;
9
+ const MAX_EVENT_QUEUE = 100;
10
+ const MAX_SNIPPET = 2_000;
11
+ const STYLE_WRITE_EXTENSIONS = new Set([".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx"]);
12
+
13
+ const APP_TARGETS = {
14
+ studio_test: {
15
+ appSlug: "studio_test",
16
+ brandSlug: "studio_test",
17
+ packageName: "@regen/studio",
18
+ command: "pnpm --filter @regen/studio dev",
19
+ url: "http://localhost:3011/editor/local-test-target",
20
+ },
21
+ "studio-test": {
22
+ appSlug: "studio_test",
23
+ brandSlug: "studio_test",
24
+ packageName: "@regen/studio",
25
+ command: "pnpm --filter @regen/studio dev",
26
+ url: "http://localhost:3011/editor/local-test-target",
27
+ },
28
+ prt: {
29
+ appSlug: "prt",
30
+ brandSlug: "prt",
31
+ packageName: "@regen/prt-portal",
32
+ command: "pnpm --filter @regen/prt-portal dev",
33
+ url: "http://localhost:3006",
34
+ },
35
+ "prt-portal": {
36
+ appSlug: "prt",
37
+ brandSlug: "prt",
38
+ packageName: "@regen/prt-portal",
39
+ command: "pnpm --filter @regen/prt-portal dev",
40
+ url: "http://localhost:3006",
41
+ },
42
+ };
43
+
44
+ function nowIso() {
45
+ return new Date().toISOString();
46
+ }
47
+
48
+ function safeString(value, max = MAX_SNIPPET) {
49
+ if (typeof value !== "string") return value;
50
+ return value.length > max ? value.slice(0, max) : value;
51
+ }
52
+
53
+ function normalizeEvent(session, body) {
54
+ const type = body.type || body.mode || "direct_edit";
55
+ const id = body.id || `evt_${crypto.randomUUID()}`;
56
+ const target = body.target && typeof body.target === "object" ? { ...body.target } : {};
57
+ if (typeof target.textSnippet === "string") target.textSnippet = safeString(target.textSnippet, 500);
58
+ if (typeof target.outerHTMLSnippet === "string") target.outerHTMLSnippet = safeString(target.outerHTMLSnippet, MAX_SNIPPET);
59
+ if (typeof target.outerHTML === "string" && !target.outerHTMLSnippet) {
60
+ target.outerHTMLSnippet = safeString(target.outerHTML, MAX_SNIPPET);
61
+ delete target.outerHTML;
62
+ }
63
+
64
+ return {
65
+ type,
66
+ id,
67
+ mode: body.mode || type,
68
+ sessionId: session.sessionId,
69
+ brandSlug: session.brandSlug,
70
+ appSlug: session.appSlug,
71
+ target,
72
+ reference: body.reference || null,
73
+ instruction: safeString(body.instruction || body.prompt || "", 4_000),
74
+ createdAt: nowIso(),
75
+ };
76
+ }
77
+
78
+ function readBody(req, maxBytes = 128 * 1024) {
79
+ return new Promise((resolve, reject) => {
80
+ let data = "";
81
+ req.on("data", chunk => {
82
+ data += chunk;
83
+ if (data.length > maxBytes) reject(new Error("Body too large"));
84
+ });
85
+ req.on("end", () => {
86
+ if (!data.trim()) return resolve({});
87
+ try {
88
+ resolve(JSON.parse(data));
89
+ } catch {
90
+ reject(new Error("Invalid JSON"));
91
+ }
92
+ });
93
+ req.on("error", reject);
94
+ });
95
+ }
96
+
97
+ function writeJson(res, status, data, cors) {
98
+ res.writeHead(status, { "Content-Type": "application/json", ...cors });
99
+ res.end(JSON.stringify(data));
100
+ }
101
+
102
+ function resolveSourceFile(session, file) {
103
+ if (!file || typeof file !== "string") return null;
104
+ const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
105
+ const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
106
+ const rel = path.relative(root, candidate);
107
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
108
+ if (!STYLE_WRITE_EXTENSIONS.has(path.extname(candidate).toLowerCase())) return null;
109
+ return { root, path: candidate, relativePath: rel.replace(/\\/g, "/") };
110
+ }
111
+
112
+ function escapeRegExp(value) {
113
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+ }
115
+
116
+ function applyCssTextEdit(source, property, value, lineNumber = null) {
117
+ const propertyPattern = new RegExp(`(${escapeRegExp(property)}\\s*:\\s*)([^;\\n]+)(\\s*;?)`);
118
+ if (Number.isInteger(lineNumber) && lineNumber > 0) {
119
+ const lines = source.split(/\r?\n/);
120
+ const index = Math.min(lines.length - 1, lineNumber - 1);
121
+ const windowStart = Math.max(0, index - 8);
122
+ const windowEnd = Math.min(lines.length - 1, index + 8);
123
+ for (let i = windowStart; i <= windowEnd; i += 1) {
124
+ if (propertyPattern.test(lines[i])) {
125
+ lines[i] = lines[i].replace(propertyPattern, `$1${value}$3`);
126
+ return { content: lines.join("\n"), strategy: "replace-near-line" };
127
+ }
128
+ }
129
+ const anchor = lines[index] ?? "";
130
+ const indent = anchor.match(/^\s*/)?.[0] ?? "";
131
+ lines.splice(index + 1, 0, `${indent}${property}: ${value};`);
132
+ return { content: lines.join("\n"), strategy: "insert-near-line" };
133
+ }
134
+ const replaced = source.replace(propertyPattern, `$1${value}$3`);
135
+ if (replaced !== source) return { content: replaced, strategy: "replace-first" };
136
+ throw new Error(`CSS property "${property}" was not found and no source line was provided.`);
137
+ }
138
+
139
+ function applyJsxStyleEdit(source, property, value, lineNumber = null) {
140
+ const lines = source.split(/\r?\n/);
141
+ const targetIndex = Number.isInteger(lineNumber) && lineNumber > 0 ? Math.min(lines.length - 1, lineNumber - 1) : -1;
142
+ const windowStart = targetIndex >= 0 ? Math.max(0, targetIndex - 8) : 0;
143
+ const windowEnd = targetIndex >= 0 ? Math.min(lines.length - 1, targetIndex + 8) : lines.length - 1;
144
+ const camel = property.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
145
+ const quoted = JSON.stringify(value);
146
+ const propPattern = new RegExp(`(${camel}\\s*:\\s*)(["'\`])([^"'\`]+)(\\2)`);
147
+
148
+ for (let i = windowStart; i <= windowEnd; i += 1) {
149
+ if (propPattern.test(lines[i])) {
150
+ lines[i] = lines[i].replace(propPattern, `$1${quoted}`);
151
+ return { content: lines.join("\n"), strategy: "replace-js-style-near-line" };
152
+ }
153
+ }
154
+
155
+ for (let i = windowStart; i <= windowEnd; i += 1) {
156
+ if (lines[i].includes("style={{")) {
157
+ lines[i] = lines[i].replace("style={{", `style={{ ${camel}: ${quoted},`);
158
+ return { content: lines.join("\n"), strategy: "insert-js-style-near-line" };
159
+ }
160
+ }
161
+
162
+ throw new Error(`No inline style object for "${property}" was found near the selected source line.`);
163
+ }
164
+
165
+ async function isUrlReachable(url) {
166
+ try {
167
+ const response = await fetch(url, {
168
+ method: "GET",
169
+ signal: AbortSignal.timeout(1_500),
170
+ });
171
+ return response.status < 500;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ function resolveTarget(input = {}) {
178
+ const slug = String(input.appSlug || input.brandSlug || "prt").toLowerCase();
179
+ const configured = APP_TARGETS[slug];
180
+ // No registration required any appSlug proceeds. Sessions without a devUrl
181
+ // or devCommand simply have no iframe target; the picker still works via extension.
182
+
183
+ const base = configured || {
184
+ appSlug: slug,
185
+ brandSlug: input.brandSlug || slug,
186
+ command: input.devCommand,
187
+ url: input.devUrl,
188
+ };
189
+
190
+ return {
191
+ appSlug: input.appSlug || base.appSlug,
192
+ brandSlug: input.brandSlug || base.brandSlug || input.appSlug || base.appSlug,
193
+ command: input.devCommand || base.command,
194
+ url: input.devUrl || base.url,
195
+ cwd: input.cwd || input.repoRoot || process.cwd(),
196
+ };
197
+ }
198
+
199
+ function splitCommand(command) {
200
+ if (!command || typeof command !== "string") return null;
201
+ const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
202
+ return parts.map(part => part.replace(/^"|"$/g, ""));
203
+ }
204
+
205
+ function startDevProcess(target, logFile) {
206
+ const parts = splitCommand(target.command);
207
+ if (!parts || parts.length === 0) {
208
+ return { started: false, error: "missing_command" };
209
+ }
210
+
211
+ const logDir = path.join(os.tmpdir(), "clauth-studio-debug");
212
+ fs.mkdirSync(logDir, { recursive: true });
213
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
214
+ const outPath = path.join(logDir, `${target.appSlug}-${stamp}.out.log`);
215
+ const errPath = path.join(logDir, `${target.appSlug}-${stamp}.err.log`);
216
+ const out = fs.openSync(outPath, "a");
217
+ const err = fs.openSync(errPath, "a");
218
+
219
+ const proc = spawn(parts[0], parts.slice(1), {
220
+ cwd: target.cwd,
221
+ env: process.env,
222
+ stdio: ["ignore", out, err],
223
+ shell: process.platform === "win32",
224
+ detached: true,
225
+ windowsHide: true,
226
+ });
227
+ proc.unref();
228
+ try {
229
+ fs.appendFileSync(logFile, `[${nowIso()}] studio-debug dev start pid=${proc.pid} command=${target.command}\n`);
230
+ } catch {}
231
+
232
+ return {
233
+ started: true,
234
+ pid: proc.pid,
235
+ command: target.command,
236
+ url: target.url,
237
+ stdout: outPath,
238
+ stderr: errPath,
239
+ process: proc,
240
+ };
241
+ }
242
+
243
+ export class StudioDebugSessionStore {
244
+ constructor({ port = 52437, logFile = path.join(os.tmpdir(), "clauth-serve.log"), dispatchAgent = null } = {}) {
245
+ this.port = port;
246
+ this.logFile = logFile;
247
+ this.sessions = new Map();
248
+ this.dispatchAgent = dispatchAgent;
249
+ }
250
+
251
+ async start(input = {}) {
252
+ const target = resolveTarget(input);
253
+ if (target.error) {
254
+ return { ok: false, error: target.error, message: target.message, status: "error" };
255
+ }
256
+
257
+ const shouldLaunch = input.launchDevServer !== false;
258
+ const reachable = await isUrlReachable(target.url);
259
+ const launch = reachable
260
+ ? { started: false, command: target.command, url: target.url, alreadyRunning: true }
261
+ : shouldLaunch
262
+ ? startDevProcess(target, this.logFile)
263
+ : { started: false, command: target.command, url: target.url, skipped: true };
264
+
265
+ const sessionId = input.sessionId || `studio-${crypto.randomUUID()}`;
266
+ const token = crypto.randomBytes(24).toString("base64url");
267
+ const session = {
268
+ sessionId,
269
+ token,
270
+ brandSlug: target.brandSlug,
271
+ appSlug: target.appSlug,
272
+ repoRoot: input.repoRoot || target.cwd,
273
+ cwd: target.cwd,
274
+ devCommand: target.command,
275
+ devUrl: target.url,
276
+ modeDefault: input.modeDefault || "direct_edit",
277
+ status: "waiting_for_agent",
278
+ createdAt: nowIso(),
279
+ updatedAt: nowIso(),
280
+ stopped: false,
281
+ events: [],
282
+ replies: [],
283
+ pendingPolls: [],
284
+ launch,
285
+ };
286
+ this.sessions.set(sessionId, session);
287
+
288
+ const relayBaseUrl = `http://127.0.0.1:${this.port}/studio/debug/${sessionId}`;
289
+ const claudeBaseUrl = `http://127.0.0.1:${this.port}/studio/claude/${sessionId}`;
290
+ const pollCommand = `node scripts/studio-debug-poll.mjs --session ${sessionId} --token ${token} --relay ${claudeBaseUrl}`;
291
+
292
+ // When dispatchAgent is wired, events dispatch directly — no polling agent needed.
293
+ if (this.dispatchAgent) {
294
+ session.status = "waiting_for_event";
295
+ }
296
+
297
+ return {
298
+ ok: true,
299
+ sessionId,
300
+ token,
301
+ devUrl: target.url,
302
+ relayBaseUrl,
303
+ status: session.status,
304
+ launch: {
305
+ started: !!launch.started,
306
+ alreadyRunning: !!launch.alreadyRunning,
307
+ command: launch.command,
308
+ url: launch.url,
309
+ pid: launch.pid || null,
310
+ },
311
+ pollCommand,
312
+ claudeBaseUrl,
313
+ };
314
+ }
315
+
316
+ get(sessionId) {
317
+ return this.sessions.get(sessionId) || null;
318
+ }
319
+
320
+ authenticate(sessionId, token) {
321
+ const session = this.get(sessionId);
322
+ if (!session) return { error: "not_found" };
323
+ if (session.token !== token) return { error: "unauthorized" };
324
+ return { session };
325
+ }
326
+
327
+ submitEvent(sessionId, body) {
328
+ const auth = this.authenticate(sessionId, body.token);
329
+ if (auth.error) return auth;
330
+ const session = auth.session;
331
+ const event = normalizeEvent(session, body);
332
+
333
+ session.updatedAt = nowIso();
334
+ session.status = "event_pending";
335
+
336
+ // If a dispatchAgent handler exists, spawn a full headless Claude agent.
337
+ if (this.dispatchAgent) {
338
+ // Stash images from the event body so the dispatcher can write them to tmp
339
+ if (Array.isArray(body.images) && body.images.length > 0) {
340
+ session._pendingImages = body.images;
341
+ }
342
+ const prompt = [
343
+ `You are a Studio local-debug agent. Make ONE source edit to the codebase.`,
344
+ ``,
345
+ `App: ${session.appSlug} (brand: ${session.brandSlug})`,
346
+ `Repo: ${session.repoRoot || session.cwd}`,
347
+ `Dev URL: ${session.devUrl || "unknown"}`,
348
+ ``,
349
+ `Edit event:`,
350
+ ` Mode: ${event.mode || "direct_edit"}`,
351
+ ` Selector: ${event.selector || "unknown"}`,
352
+ ` Instruction: ${event.instruction || "Make the requested edit."}`,
353
+ event.textSnippet ? ` Current text: "${event.textSnippet}"` : "",
354
+ event.textEdit ? ` New text: "${event.textEdit.newText || ""}"` : "",
355
+ event.file ? ` File hint: ${event.file}` : "",
356
+ event.notes ? ` Notes: ${event.notes}` : "",
357
+ ``,
358
+ `Find the source file in the repo that renders this element and make the edit.`,
359
+ `After editing, output a JSON object: {"status":"done","message":"<what you did>","filesChanged":["<path>"]}`,
360
+ ].filter(Boolean).join("\n");
361
+
362
+ this.dispatchAgent(event.id, session.token, null, session, prompt)
363
+ .then(result => {
364
+ let parsed = {};
365
+ try { parsed = JSON.parse((result?.stdout || result?.output || "").trim()); } catch {}
366
+ const reply = {
367
+ eventId: event.id,
368
+ status: parsed.status || "done",
369
+ message: parsed.message || result?.stdout?.slice(0, 500) || "Agent completed",
370
+ filesChanged: parsed.filesChanged || [],
371
+ createdAt: nowIso(),
372
+ };
373
+ session.replies.push(reply);
374
+ session.status = "done";
375
+ session.updatedAt = nowIso();
376
+ })
377
+ .catch(() => {
378
+ session.replies.push({
379
+ eventId: event.id,
380
+ status: "error",
381
+ message: "Agent dispatch failed",
382
+ filesChanged: [],
383
+ createdAt: nowIso(),
384
+ });
385
+ session.status = "error";
386
+ session.updatedAt = nowIso();
387
+ });
388
+
389
+ return { ok: true, eventId: event.id, status: session.status };
390
+ }
391
+
392
+ // Legacy path: queue for a polling agent
393
+ if (session.pendingPolls.length > 0) {
394
+ const poll = session.pendingPolls.shift();
395
+ poll(event);
396
+ } else {
397
+ session.events.push(event);
398
+ if (session.events.length > MAX_EVENT_QUEUE) session.events.shift();
399
+ }
400
+ return { ok: true, eventId: event.id, status: session.status };
401
+ }
402
+
403
+ poll(sessionId, token, timeoutMs = DEFAULT_POLL_TIMEOUT) {
404
+ const auth = this.authenticate(sessionId, token);
405
+ if (auth.error) return Promise.resolve(auth);
406
+ const session = auth.session;
407
+ if (session.events.length > 0) {
408
+ session.status = "agent_received";
409
+ session.updatedAt = nowIso();
410
+ return Promise.resolve(session.events.shift());
411
+ }
412
+ if (session.stopped) return Promise.resolve({ type: "stopped" });
413
+
414
+ const timeout = Math.min(Math.max(Number(timeoutMs) || DEFAULT_POLL_TIMEOUT, 1), MAX_POLL_TIMEOUT);
415
+ session.status = "waiting_for_event";
416
+ session.updatedAt = nowIso();
417
+
418
+ return new Promise(resolve => {
419
+ const timer = setTimeout(() => {
420
+ session.pendingPolls = session.pendingPolls.filter(fn => fn !== finish);
421
+ resolve({ type: "timeout" });
422
+ }, timeout);
423
+ const finish = event => {
424
+ clearTimeout(timer);
425
+ session.status = "agent_received";
426
+ session.updatedAt = nowIso();
427
+ resolve(event);
428
+ };
429
+ session.pendingPolls.push(finish);
430
+ });
431
+ }
432
+
433
+ reply(sessionId, body) {
434
+ const auth = this.authenticate(sessionId, body.token);
435
+ if (auth.error) return auth;
436
+ const session = auth.session;
437
+ const reply = {
438
+ eventId: body.eventId,
439
+ status: body.status || body.type || "done",
440
+ message: body.message || "",
441
+ filesChanged: Array.isArray(body.filesChanged)
442
+ ? body.filesChanged
443
+ : body.file
444
+ ? [body.file]
445
+ : [],
446
+ createdAt: nowIso(),
447
+ };
448
+ session.replies.push(reply);
449
+ session.status = reply.status === "done" ? "done" : reply.status;
450
+ session.updatedAt = nowIso();
451
+ return { ok: true, status: session.status, reply };
452
+ }
453
+
454
+ writeStyle(sessionId, body) {
455
+ const auth = this.authenticate(sessionId, body.token);
456
+ if (auth.error) return auth;
457
+ const session = auth.session;
458
+ const styleEdit = body.styleEdit && typeof body.styleEdit === "object" ? body.styleEdit : {};
459
+ const deltas = Array.isArray(styleEdit.deltas) ? styleEdit.deltas : [];
460
+ const source = styleEdit.source && typeof styleEdit.source === "object" ? styleEdit.source : {};
461
+ const fileInfo = resolveSourceFile(session, source.file || body.file);
462
+ if (!fileInfo) return { error: "invalid_source", message: "style-write requires a source file inside the debug session repo." };
463
+ if (deltas.length !== 1) return { error: "invalid_delta", message: "style-write currently accepts exactly one deterministic CSS delta." };
464
+
465
+ const delta = deltas[0] || {};
466
+ if (typeof delta.property !== "string" || typeof delta.value !== "string") {
467
+ return { error: "invalid_delta", message: "style-write requires string property and value." };
468
+ }
469
+
470
+ try {
471
+ const original = fs.readFileSync(fileInfo.path, "utf8");
472
+ const ext = path.extname(fileInfo.path).toLowerCase();
473
+ const result = ext === ".jsx" || ext === ".tsx"
474
+ ? applyJsxStyleEdit(original, delta.property, delta.value, source.line || body.line || null)
475
+ : applyCssTextEdit(original, delta.property, delta.value, source.line || body.line || null);
476
+ fs.writeFileSync(fileInfo.path, result.content, "utf8");
477
+ const reply = {
478
+ eventId: body.id || `style_${crypto.randomUUID()}`,
479
+ status: "done",
480
+ message: `Direct style write (${result.strategy})`,
481
+ filesChanged: [fileInfo.relativePath],
482
+ createdAt: nowIso(),
483
+ };
484
+ session.replies.push(reply);
485
+ session.status = "done";
486
+ session.updatedAt = nowIso();
487
+ return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged, strategy: result.strategy };
488
+ } catch (err) {
489
+ return { error: "style_write_failed", message: err instanceof Error ? err.message : String(err) };
490
+ }
491
+ }
492
+
493
+ status(sessionId, token) {
494
+ const auth = this.authenticate(sessionId, token);
495
+ if (auth.error) return auth;
496
+ const session = auth.session;
497
+ return {
498
+ ok: true,
499
+ sessionId: session.sessionId,
500
+ brandSlug: session.brandSlug,
501
+ appSlug: session.appSlug,
502
+ devUrl: session.devUrl,
503
+ status: session.status,
504
+ createdAt: session.createdAt,
505
+ updatedAt: session.updatedAt,
506
+ pendingEvents: session.events.length,
507
+ pendingPolls: session.pendingPolls.length,
508
+ replies: session.replies,
509
+ launch: {
510
+ started: !!session.launch?.started,
511
+ alreadyRunning: !!session.launch?.alreadyRunning,
512
+ command: session.launch?.command || session.devCommand,
513
+ url: session.launch?.url || session.devUrl,
514
+ pid: session.launch?.pid || null,
515
+ },
516
+ };
517
+ }
518
+
519
+ stop(sessionId, token) {
520
+ const auth = this.authenticate(sessionId, token);
521
+ if (auth.error) return auth;
522
+ const session = auth.session;
523
+ session.stopped = true;
524
+ session.status = "stopped";
525
+ session.updatedAt = nowIso();
526
+ if (session.launch?.process && !session.launch.process.killed) {
527
+ try {
528
+ session.launch.process.kill();
529
+ } catch {}
530
+ }
531
+ for (const poll of session.pendingPolls.splice(0)) poll({ type: "stopped" });
532
+ this.sessions.delete(sessionId);
533
+ return { ok: true, status: "stopped", sessionId };
534
+ }
535
+ }
536
+
537
+ export function createStudioDebugRuntime(options) {
538
+ const store = new StudioDebugSessionStore(options);
539
+
540
+ async function handle(req, res, url, cors) {
541
+ const reqPath = url.pathname;
542
+ const method = req.method;
543
+
544
+ if (method === "POST" && reqPath === "/studio/debug/start") {
545
+ try {
546
+ const body = await readBody(req);
547
+ const result = await store.start(body);
548
+ return writeJson(res, result.ok ? 200 : 400, result, cors);
549
+ } catch (err) {
550
+ return writeJson(res, 400, { ok: false, error: err.message }, cors);
551
+ }
552
+ }
553
+
554
+ const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|status|stop)$/);
555
+ const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
556
+ if (!debugMatch && !claudeMatch) return false;
557
+ const [, sessionId, action] = debugMatch || claudeMatch;
558
+
559
+ try {
560
+ if (method === "POST" && action === "events") {
561
+ const result = store.submitEvent(sessionId, await readBody(req));
562
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
563
+ }
564
+ if (method === "POST" && action === "style-write") {
565
+ const result = store.writeStyle(sessionId, await readBody(req));
566
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
567
+ }
568
+ if (method === "GET" && action === "poll") {
569
+ const result = await store.poll(sessionId, url.searchParams.get("token"), url.searchParams.get("timeout"));
570
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
571
+ }
572
+ if (method === "POST" && action === "reply") {
573
+ const result = store.reply(sessionId, await readBody(req));
574
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
575
+ }
576
+ if (method === "GET" && action === "status") {
577
+ const result = store.status(sessionId, url.searchParams.get("token"));
578
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
579
+ }
580
+ if (method === "POST" && action === "stop") {
581
+ const body = await readBody(req);
582
+ const result = store.stop(sessionId, body.token || url.searchParams.get("token"));
583
+ return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
584
+ }
585
+ return writeJson(res, 405, { ok: false, error: "method_not_allowed" }, cors);
586
+ } catch (err) {
587
+ return writeJson(res, 400, { ok: false, error: err.message }, cors);
588
+ }
589
+ }
590
+
591
+ return { store, handle };
592
+ }
593
+
594
+ export const studioDebugTargets = APP_TARGETS;