@tuongaz/seeflow 0.1.3

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/src/api.ts ADDED
@@ -0,0 +1,1093 @@
1
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
+ import { Hono } from 'hono';
4
+ import { streamSSE } from 'hono/streaming';
5
+ import { z } from 'zod';
6
+ import {
7
+ AssembleRequestSchema,
8
+ ProposeScopeRequestSchema,
9
+ ValidateRequestSchema,
10
+ assembleDemo,
11
+ proposeScope,
12
+ validateDemo,
13
+ } from './diagram.ts';
14
+ import type { EventBus } from './events.ts';
15
+ import {
16
+ ConnectorPatchBodySchema,
17
+ CreateProjectBodySchema,
18
+ NodePatchBodySchema,
19
+ PositionBodySchema,
20
+ RegisterBodySchema,
21
+ ReorderBodySchema,
22
+ addConnectorImpl,
23
+ addNodeImpl,
24
+ createProjectImpl,
25
+ deleteConnectorImpl,
26
+ deleteDemoImpl,
27
+ deleteNodeImpl,
28
+ getDemoImpl,
29
+ listDemosImpl,
30
+ moveNodeImpl,
31
+ patchConnectorImpl,
32
+ patchNodeImpl,
33
+ registerDemoImpl,
34
+ reorderNodeImpl,
35
+ resolveDemoPath,
36
+ } from './operations.ts';
37
+ import type { ProcessSpawner } from './process-spawner.ts';
38
+ import {
39
+ type PlayResult,
40
+ type ResetResult,
41
+ type RunPlayOptions,
42
+ type RunResetOptions,
43
+ runPlay as defaultRunPlay,
44
+ runReset as defaultRunReset,
45
+ stopAllPlays as defaultStopAllPlays,
46
+ } from './proxy.ts';
47
+ import type { Registry } from './registry.ts';
48
+ import { DemoSchema } from './schema.ts';
49
+ import { type Spawner, defaultSpawner } from './shellout.ts';
50
+ import type { StatusRunner } from './status-runner.ts';
51
+ import type { DemoWatcher } from './watcher.ts';
52
+
53
+ const EmitBodySchema = z.object({
54
+ demoId: z.string().min(1),
55
+ nodeId: z.string().min(1),
56
+ status: z.enum(['running', 'done', 'error']),
57
+ runId: z.string().optional(),
58
+ payload: z.unknown().optional(),
59
+ });
60
+
61
+ type RelativePathCheck = { kind: 'ok' } | { kind: 'invalid'; reason: string };
62
+
63
+ // Reject absolute paths and `..` traversal before any filesystem touch.
64
+ // Realpath verification is layered on top by the caller for symlink defense.
65
+ const validateRelativePath = (path: string): RelativePathCheck => {
66
+ if (path.length === 0) return { kind: 'invalid', reason: 'path is empty' };
67
+ if (isAbsolute(path) || path.startsWith('/') || path.startsWith('\\')) {
68
+ return { kind: 'invalid', reason: 'absolute paths are not allowed' };
69
+ }
70
+ const segments = path.split(/[\\/]/);
71
+ if (segments.some((s) => s === '..')) {
72
+ return { kind: 'invalid', reason: 'path traversal is not allowed' };
73
+ }
74
+ return { kind: 'ok' };
75
+ };
76
+
77
+ const EMIT_STATUS_TO_EVENT = {
78
+ running: 'node:running',
79
+ done: 'node:done',
80
+ error: 'node:error',
81
+ } as const;
82
+
83
+ const FilePathBodySchema = z.object({ path: z.string() });
84
+
85
+ type ResolvedProjectFile =
86
+ | { kind: 'ok'; absPath: string; seeflowRoot: string }
87
+ | { kind: 'unknownProject' }
88
+ | { kind: 'invalidPath'; reason: string }
89
+ | { kind: 'fileMissing'; absPath: string };
90
+
91
+ // Shared path-safety + filesystem resolution for project-scoped file routes.
92
+ // Performs textual rejection of absolute paths / `..` traversal, then layered
93
+ // realpath verification that the resolved file stays inside `<project>/.seeflow/`
94
+ // (defense against symlink escapes). Returns the realpath of an existing file
95
+ // on success, or `fileMissing` with the would-be absolute path so callers can
96
+ // soft-fail with that path included for clipboard fallback.
97
+ function resolveProjectFile(
98
+ registry: Registry,
99
+ projectId: string,
100
+ relPath: string,
101
+ ): ResolvedProjectFile {
102
+ const entry = registry.getById(projectId);
103
+ if (!entry) return { kind: 'unknownProject' };
104
+
105
+ const guard = validateRelativePath(relPath);
106
+ if (guard.kind === 'invalid') return { kind: 'invalidPath', reason: guard.reason };
107
+
108
+ const seeflowRoot = join(entry.repoPath, '.seeflow');
109
+ let realRoot: string;
110
+ try {
111
+ realRoot = realpathSync(seeflowRoot);
112
+ } catch {
113
+ return { kind: 'fileMissing', absPath: resolve(seeflowRoot, relPath) };
114
+ }
115
+
116
+ const target = resolve(seeflowRoot, relPath);
117
+ let realTarget: string;
118
+ try {
119
+ realTarget = realpathSync(target);
120
+ } catch {
121
+ return { kind: 'fileMissing', absPath: target };
122
+ }
123
+
124
+ const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
125
+ if (realTarget !== realRoot && !realTarget.startsWith(rootWithSep)) {
126
+ return { kind: 'invalidPath', reason: 'path escapes project root' };
127
+ }
128
+
129
+ return { kind: 'ok', absPath: realTarget, seeflowRoot: realRoot };
130
+ }
131
+
132
+ // Allowed extensions for /files/upload. Lowercased; matched after dropping the
133
+ // leading `.`. Stored as a Set so future expansion (PDF, video) is one-edit.
134
+ const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
135
+ const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
136
+
137
+ // Turn a user-supplied filename into a `<slug>.<ext>` pair. Returns null when
138
+ // the extension isn't on the allowlist or the slug is empty after sanitization.
139
+ function sanitizeUploadFilename(name: string): { base: string; ext: string } | null {
140
+ const last = name.split(/[\\/]/).pop() ?? name;
141
+ const dotIdx = last.lastIndexOf('.');
142
+ if (dotIdx <= 0 || dotIdx === last.length - 1) return null;
143
+ const ext = last.slice(dotIdx).toLowerCase();
144
+ if (!UPLOAD_ALLOWED_EXTS.has(ext)) return null;
145
+ const slug = last
146
+ .slice(0, dotIdx)
147
+ .toLowerCase()
148
+ .replace(/[^a-z0-9]+/g, '-')
149
+ .replace(/^-+|-+$/g, '');
150
+ if (slug.length === 0) return null;
151
+ return { base: slug, ext };
152
+ }
153
+
154
+ // Find the first unused `<base>.<ext>` (then `<base>-2.<ext>`, `<base>-3.<ext>`,
155
+ // …) inside `assetsDir`. Caps at 999 attempts to avoid an unbounded loop on a
156
+ // pathologically full directory.
157
+ function pickUploadFilename(assetsDir: string, base: string, ext: string): string {
158
+ const first = `${base}${ext}`;
159
+ if (!existsSync(join(assetsDir, first))) return first;
160
+ for (let i = 2; i < 1000; i++) {
161
+ const candidate = `${base}-${i}${ext}`;
162
+ if (!existsSync(join(assetsDir, candidate))) return candidate;
163
+ }
164
+ return `${base}-${Date.now()}${ext}`;
165
+ }
166
+
167
+ export interface ApiOptions {
168
+ registry: Registry;
169
+ events?: EventBus;
170
+ watcher?: DemoWatcher;
171
+ /** Injectable shellout for tests; defaults to Bun.spawn fire-and-forget. */
172
+ spawner?: Spawner;
173
+ /** Override `process.platform` for tests covering darwin/win32/linux branches. */
174
+ platform?: NodeJS.Platform;
175
+ /** Long-running statusAction runner; fanned out on each /play click. */
176
+ statusRunner?: StatusRunner;
177
+ /** Injectable ProcessSpawner threaded into runPlay; tests use this to avoid
178
+ * launching real child processes for the play-action script. */
179
+ processSpawner?: ProcessSpawner;
180
+ /** Injectable proxy facade — defaults wrap the proxy.ts module exports.
181
+ * Tests use this to record call order across runPlay / runReset /
182
+ * stopAllPlays and to drive each in isolation. */
183
+ proxy?: ProxyFacade;
184
+ /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
185
+ projectBaseDir?: string;
186
+ }
187
+
188
+ /**
189
+ * Thin call-through wrapper around the proxy.ts module exports. Lets tests
190
+ * inject a recording fake to assert call order across runPlay, runReset, and
191
+ * stopAllPlays — none of which can be observed via the underlying
192
+ * ProcessSpawner alone because the play-run map and event broadcasts are
193
+ * encapsulated inside proxy.ts.
194
+ */
195
+ export interface ProxyFacade {
196
+ runPlay(options: RunPlayOptions): Promise<PlayResult>;
197
+ runReset(options: RunResetOptions): Promise<ResetResult>;
198
+ stopAllPlays(demoId: string): Promise<void>;
199
+ }
200
+
201
+ export const defaultProxyFacade: ProxyFacade = {
202
+ runPlay: defaultRunPlay,
203
+ runReset: defaultRunReset,
204
+ stopAllPlays: defaultStopAllPlays,
205
+ };
206
+
207
+ export function createApi(options: ApiOptions): Hono {
208
+ const { registry, events, watcher, statusRunner } = options;
209
+ const spawner = options.spawner ?? defaultSpawner;
210
+ const platform = options.platform ?? process.platform;
211
+ const processSpawner = options.processSpawner;
212
+ const proxy = options.proxy ?? defaultProxyFacade;
213
+ const projectBaseDir = options.projectBaseDir;
214
+ const api = new Hono();
215
+
216
+ api.post('/demos/register', async (c) => {
217
+ let body: unknown;
218
+ try {
219
+ body = await c.req.json();
220
+ } catch {
221
+ return c.json({ error: 'Body must be valid JSON' }, 400);
222
+ }
223
+
224
+ const parsed = RegisterBodySchema.safeParse(body);
225
+ if (!parsed.success) {
226
+ return c.json({ error: 'Invalid register body', issues: parsed.error.issues }, 400);
227
+ }
228
+
229
+ const result = await registerDemoImpl({ registry, watcher }, parsed.data);
230
+ switch (result.kind) {
231
+ case 'ok':
232
+ return c.json(result.data);
233
+ case 'fileNotFound':
234
+ return c.json({ error: `Demo file not found: ${result.path}` }, 400);
235
+ case 'badJson':
236
+ return c.json({ error: 'Demo file is not valid JSON', detail: result.detail }, 400);
237
+ case 'badSchema':
238
+ return c.json({ error: 'Demo file failed schema validation', issues: result.issues }, 400);
239
+ case 'sdkWriteFailed':
240
+ return c.json(
241
+ {
242
+ error: `Failed to write SDK helper: ${result.message}`,
243
+ id: result.id,
244
+ slug: result.slug,
245
+ },
246
+ 500,
247
+ );
248
+ }
249
+ });
250
+
251
+ // POST /api/demos/validate — dry-run validation. The skill's diagram
252
+ // pipeline calls this between assemble and register to decide whether to
253
+ // rewire. Runs the Zod schema, the soft node cap, and the tier playability
254
+ // check. Filesystem-bound checks (harness coverage, event emitter index)
255
+ // stay in the skill since the studio doesn't see the user's $TARGET.
256
+ api.post('/demos/validate', async (c) => {
257
+ let body: unknown;
258
+ try {
259
+ body = await c.req.json();
260
+ } catch {
261
+ return c.json({ error: 'Body must be valid JSON' }, 400);
262
+ }
263
+ const parsed = ValidateRequestSchema.safeParse(body);
264
+ if (!parsed.success) {
265
+ return c.json({ error: 'Invalid validate body', issues: parsed.error.issues }, 400);
266
+ }
267
+ return c.json(validateDemo(parsed.data));
268
+ });
269
+
270
+ // POST /api/diagram/propose-scope — Phase 2 helper. The skill POSTs the
271
+ // scan-result.json shape and gets back ranked entry-point candidates.
272
+ // Pure compute; skill writes the response to intermediate/entry-candidates.json.
273
+ api.post('/diagram/propose-scope', async (c) => {
274
+ let body: unknown;
275
+ try {
276
+ body = await c.req.json();
277
+ } catch {
278
+ return c.json({ error: 'Body must be valid JSON' }, 400);
279
+ }
280
+ const parsed = ProposeScopeRequestSchema.safeParse(body);
281
+ if (!parsed.success) {
282
+ return c.json({ error: 'Invalid propose-scope body', issues: parsed.error.issues }, 400);
283
+ }
284
+ return c.json(proposeScope(parsed.data));
285
+ });
286
+
287
+ // POST /api/diagram/assemble — Phase 7a. The skill POSTs wiring + layout
288
+ // and gets back the assembled demo (IDs normalized, dupes dropped, dangling
289
+ // connectors removed, positions snapped to a 24px grid). Pure compute; the
290
+ // skill writes the response to $TARGET/.seeflow/seeflow.json. No schema
291
+ // validation here — call /demos/validate for that.
292
+ api.post('/diagram/assemble', async (c) => {
293
+ let body: unknown;
294
+ try {
295
+ body = await c.req.json();
296
+ } catch {
297
+ return c.json({ error: 'Body must be valid JSON' }, 400);
298
+ }
299
+ const parsed = AssembleRequestSchema.safeParse(body);
300
+ if (!parsed.success) {
301
+ return c.json({ error: 'Invalid assemble body', issues: parsed.error.issues }, 400);
302
+ }
303
+ return c.json(assembleDemo(parsed.data));
304
+ });
305
+
306
+ // POST /api/projects — UI-driven "Create new project" flow (US-020). Two
307
+ // branches based on whether the target folder already has a SeeFlow
308
+ // project set up at `<folderPath>/.seeflow/seeflow.json`:
309
+ // 1. Existing setup: read + validate the on-disk demo and register it
310
+ // as-is (no overwrite, no scaffolding). The user-supplied `name`
311
+ // becomes the registry display name; the on-disk demo's `name` is
312
+ // preserved on disk.
313
+ // 2. Fresh scaffold: mkdir -p the folder + .seeflow/, write a default
314
+ // scaffold seeflow.json keyed off `name`, and run the same SDK-emit
315
+ // helper write the CLI register flow uses (a no-op for an empty
316
+ // scaffold, but kept for parity).
317
+ api.post('/projects', async (c) => {
318
+ let body: unknown;
319
+ try {
320
+ body = await c.req.json();
321
+ } catch {
322
+ return c.json({ error: 'Body must be valid JSON' }, 400);
323
+ }
324
+
325
+ const parsed = CreateProjectBodySchema.safeParse(body);
326
+ if (!parsed.success) {
327
+ return c.json({ error: 'Invalid create project body', issues: parsed.error.issues }, 400);
328
+ }
329
+
330
+ const result = await createProjectImpl({ registry, watcher, projectBaseDir }, parsed.data);
331
+ switch (result.kind) {
332
+ case 'ok':
333
+ return c.json(result.data);
334
+ case 'badJson':
335
+ return c.json({ error: `Existing demo file is not valid JSON: ${result.detail}` }, 400);
336
+ case 'badSchema':
337
+ return c.json(
338
+ { error: 'Existing demo file failed schema validation', issues: result.issues },
339
+ 400,
340
+ );
341
+ case 'scaffoldFailed':
342
+ return c.json({ error: `Failed to scaffold project: ${result.message}` }, 500);
343
+ case 'sdkWriteFailed':
344
+ return c.json({ error: `Failed to write SDK helper: ${result.message}` }, 500);
345
+ }
346
+ });
347
+
348
+ api.get('/demos', (c) => {
349
+ const result = listDemosImpl({ registry });
350
+ return c.json(result.data);
351
+ });
352
+
353
+ api.get('/demos/:id', async (c) => {
354
+ const result = await getDemoImpl({ registry, watcher }, c.req.param('id'));
355
+ switch (result.kind) {
356
+ case 'ok':
357
+ return c.json(result.data);
358
+ case 'notFound':
359
+ return c.json({ error: 'not found' }, 404);
360
+ case 'fileNotFound':
361
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
362
+ }
363
+ });
364
+
365
+ // GET /api/projects/:id/files/<path> — stream a project-scoped file from
366
+ // <repoPath>/.seeflow/<path>. Path safety is layered: textual rejection
367
+ // (absolute / traversal), then realpath check that the resolved file stays
368
+ // inside the project's .seeflow root (defends against symlink escapes).
369
+ api.get('/projects/:id/files/:path{.+}', async (c) => {
370
+ const rawPath = c.req.param('path');
371
+ let relPath: string;
372
+ try {
373
+ relPath = decodeURIComponent(rawPath);
374
+ } catch {
375
+ return c.json({ error: 'invalid path encoding' }, 400);
376
+ }
377
+
378
+ const resolved = resolveProjectFile(registry, c.req.param('id'), relPath);
379
+ switch (resolved.kind) {
380
+ case 'unknownProject':
381
+ return c.json({ error: 'unknown project' }, 404);
382
+ case 'invalidPath':
383
+ return c.json({ error: resolved.reason }, 400);
384
+ case 'fileMissing':
385
+ return c.json({ error: 'file not found' }, 404);
386
+ }
387
+
388
+ const file = Bun.file(resolved.absPath);
389
+ if (!(await file.exists())) {
390
+ return c.json({ error: 'file not found' }, 404);
391
+ }
392
+
393
+ return new Response(file.stream(), {
394
+ headers: {
395
+ 'content-type': file.type || 'application/octet-stream',
396
+ 'content-length': String(file.size),
397
+ },
398
+ });
399
+ });
400
+
401
+ // POST /api/projects/:id/files/open — shell out to `$EDITOR <abs>` so the
402
+ // user can edit a project-scoped file (htmlNode block, image asset) in
403
+ // their IDE. The endpoint always returns the resolved absolute path in
404
+ // the response body so the frontend can copy-to-clipboard when $EDITOR
405
+ // isn't set or the spawn fails. Path safety mirrors the GET route.
406
+ api.post('/projects/:id/files/open', async (c) => {
407
+ let body: unknown;
408
+ try {
409
+ body = await c.req.json();
410
+ } catch {
411
+ return c.json({ error: 'Body must be valid JSON' }, 400);
412
+ }
413
+ const parsed = FilePathBodySchema.safeParse(body);
414
+ if (!parsed.success) {
415
+ return c.json({ error: 'Invalid open body', issues: parsed.error.issues }, 400);
416
+ }
417
+
418
+ const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
419
+ switch (resolved.kind) {
420
+ case 'unknownProject':
421
+ return c.json({ error: 'unknown project' }, 404);
422
+ case 'invalidPath':
423
+ return c.json({ error: resolved.reason }, 400);
424
+ case 'fileMissing':
425
+ return c.json({ error: 'file not found', absPath: resolved.absPath }, 404);
426
+ }
427
+
428
+ const editor = process.env.EDITOR;
429
+ if (!editor || editor.trim().length === 0) {
430
+ return c.json({ ok: false, absPath: resolved.absPath, error: 'EDITOR not set' });
431
+ }
432
+
433
+ const run = await spawner(editor, [resolved.absPath]);
434
+ if (!run.ok) {
435
+ return c.json({ ok: false, absPath: resolved.absPath, error: run.error ?? 'spawn failed' });
436
+ }
437
+ return c.json({ ok: true, absPath: resolved.absPath });
438
+ });
439
+
440
+ // POST /api/projects/:id/files/reveal — open the OS file manager with the
441
+ // target file selected. Platform commands: `open -R <abs>` (macOS),
442
+ // `explorer /select,<abs>` (Windows), `xdg-open <dir>` (Linux — selects the
443
+ // containing directory; xdg has no portable "select-this-file" verb). Same
444
+ // fallback shape as /open: response always includes `absPath` for clipboard.
445
+ api.post('/projects/:id/files/reveal', async (c) => {
446
+ let body: unknown;
447
+ try {
448
+ body = await c.req.json();
449
+ } catch {
450
+ return c.json({ error: 'Body must be valid JSON' }, 400);
451
+ }
452
+ const parsed = FilePathBodySchema.safeParse(body);
453
+ if (!parsed.success) {
454
+ return c.json({ error: 'Invalid reveal body', issues: parsed.error.issues }, 400);
455
+ }
456
+
457
+ const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
458
+ switch (resolved.kind) {
459
+ case 'unknownProject':
460
+ return c.json({ error: 'unknown project' }, 404);
461
+ case 'invalidPath':
462
+ return c.json({ error: resolved.reason }, 400);
463
+ case 'fileMissing':
464
+ return c.json({ error: 'file not found', absPath: resolved.absPath }, 404);
465
+ }
466
+
467
+ let cmd: string;
468
+ let args: string[];
469
+ switch (platform) {
470
+ case 'darwin':
471
+ cmd = 'open';
472
+ args = ['-R', resolved.absPath];
473
+ break;
474
+ case 'win32':
475
+ cmd = 'explorer';
476
+ args = [`/select,${resolved.absPath}`];
477
+ break;
478
+ default:
479
+ cmd = 'xdg-open';
480
+ args = [dirname(resolved.absPath)];
481
+ break;
482
+ }
483
+
484
+ const run = await spawner(cmd, args);
485
+ if (!run.ok) {
486
+ return c.json({ ok: false, absPath: resolved.absPath, error: run.error ?? 'spawn failed' });
487
+ }
488
+ return c.json({ ok: true, absPath: resolved.absPath });
489
+ });
490
+
491
+ // POST /api/projects/:id/files/upload — accept a multipart image upload and
492
+ // persist it under `<project>/.seeflow/assets/`. The frontend (US-008 OS
493
+ // drop) sends `file` (Blob) and optionally `filename` (the original OS name)
494
+ // in a multipart form; we sanitize the filename to a lowercased slug,
495
+ // dedupe with `-2`, `-3` suffixes inside the assets dir, and return the
496
+ // demo-relative path. Allowlist + 5 MB cap guard against arbitrary uploads.
497
+ api.post('/projects/:id/files/upload', async (c) => {
498
+ const projectId = c.req.param('id');
499
+ const entry = registry.getById(projectId);
500
+ if (!entry) return c.json({ error: 'unknown project' }, 404);
501
+
502
+ let form: FormData;
503
+ try {
504
+ form = await c.req.formData();
505
+ } catch {
506
+ return c.json({ error: 'Body must be valid multipart form-data' }, 400);
507
+ }
508
+
509
+ const fileField = form.get('file');
510
+ if (!(fileField instanceof File)) {
511
+ return c.json({ error: 'Missing file field' }, 400);
512
+ }
513
+ if (fileField.size > UPLOAD_MAX_BYTES) {
514
+ return c.json({ error: 'file too large', maxBytes: UPLOAD_MAX_BYTES }, 413);
515
+ }
516
+
517
+ const suggestedRaw = form.get('filename');
518
+ const suggested =
519
+ typeof suggestedRaw === 'string' && suggestedRaw.length > 0 ? suggestedRaw : fileField.name;
520
+ const sanitized = sanitizeUploadFilename(suggested);
521
+ if (!sanitized) {
522
+ return c.json({ error: 'invalid filename or extension' }, 400);
523
+ }
524
+
525
+ const assetsDir = join(entry.repoPath, '.seeflow', 'assets');
526
+ try {
527
+ mkdirSync(assetsDir, { recursive: true });
528
+ } catch (err) {
529
+ return c.json(
530
+ {
531
+ error: `Failed to create assets dir: ${err instanceof Error ? err.message : String(err)}`,
532
+ },
533
+ 500,
534
+ );
535
+ }
536
+
537
+ const finalName = pickUploadFilename(assetsDir, sanitized.base, sanitized.ext);
538
+ const absPath = join(assetsDir, finalName);
539
+ try {
540
+ await Bun.write(absPath, fileField);
541
+ } catch (err) {
542
+ return c.json(
543
+ { error: `Failed to write file: ${err instanceof Error ? err.message : String(err)}` },
544
+ 500,
545
+ );
546
+ }
547
+
548
+ return c.json({ path: `assets/${finalName}` });
549
+ });
550
+
551
+ api.delete('/demos/:id', (c) => {
552
+ const result = deleteDemoImpl({ registry, watcher }, c.req.param('id'));
553
+ switch (result.kind) {
554
+ case 'ok':
555
+ return c.json({ ok: true });
556
+ case 'notFound':
557
+ return c.json({ ok: false, error: 'not found' }, 404);
558
+ }
559
+ });
560
+
561
+ api.post('/demos/:id/play/:nodeId', async (c) => {
562
+ const id = c.req.param('id');
563
+ const nodeId = c.req.param('nodeId');
564
+ const entry = registry.getById(id);
565
+ if (!entry) return c.json({ error: 'unknown demo' }, 404);
566
+ if (!events) return c.json({ error: 'events not enabled' }, 500);
567
+
568
+ // Always re-read from disk so the user's most recent edit (validated or
569
+ // not yet observed by the watcher) drives the actual fetch.
570
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
571
+ if (!existsSync(fullPath)) {
572
+ return c.json({ error: `Demo file not found: ${fullPath}` }, 404);
573
+ }
574
+ let raw: unknown;
575
+ try {
576
+ raw = await Bun.file(fullPath).json();
577
+ } catch (err) {
578
+ return c.json(
579
+ {
580
+ error: `Demo file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
581
+ },
582
+ 400,
583
+ );
584
+ }
585
+ const parsed = DemoSchema.safeParse(raw);
586
+ if (!parsed.success) {
587
+ return c.json({ error: 'Demo failed schema validation', issues: parsed.error.issues }, 400);
588
+ }
589
+
590
+ const node = parsed.data.nodes.find((n) => n.id === nodeId);
591
+ if (!node) return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
592
+ if (
593
+ node.type === 'shapeNode' ||
594
+ node.type === 'imageNode' ||
595
+ node.type === 'iconNode' ||
596
+ node.type === 'htmlNode' ||
597
+ !node.data.playAction
598
+ ) {
599
+ return c.json({ error: `Node ${nodeId} has no playAction` }, 400);
600
+ }
601
+
602
+ // Fan out the long-running statusAction scripts BEFORE awaiting the play
603
+ // spawn — fire-and-forget so a slow status batch can't delay the click.
604
+ // Individual spawn failures are surfaced via console.warn but never fail
605
+ // the /play call itself.
606
+ if (statusRunner) {
607
+ void statusRunner.restart(id).catch((err) => {
608
+ console.warn(
609
+ `[api] statusRunner.restart(${id}) failed: ${err instanceof Error ? err.message : String(err)}`,
610
+ );
611
+ });
612
+ }
613
+
614
+ const result = await proxy.runPlay({
615
+ events,
616
+ demoId: id,
617
+ nodeId,
618
+ cwd: entry.repoPath,
619
+ action: node.data.playAction,
620
+ spawner: processSpawner,
621
+ });
622
+
623
+ // Surface the symlink-escape error as a 400 so the frontend can show a
624
+ // distinct "fix your scriptPath" message instead of a generic run failure.
625
+ if (result.error === 'scriptPath escapes project root') {
626
+ return c.json({ error: result.error }, 400);
627
+ }
628
+ return c.json(result);
629
+ });
630
+
631
+ // POST /api/demos/:id/reset — the "Restart demo" workflow (US-008). Order:
632
+ // 1. Stop every live play-script + every long-running status-script for
633
+ // this demo in parallel — both must complete before any reset script
634
+ // spawns so the script sees no stragglers.
635
+ // 2. Run the demo's `resetAction` script (if declared); any non-zero exit
636
+ // becomes a 502 to the caller but does NOT suppress reload/restart.
637
+ // 3. Broadcast `demo:reload` unconditionally so the canvas re-fetches.
638
+ // 4. Fire-and-forget `statusRunner.restart` so the next status batch is
639
+ // spawning by the time the response lands. Individual spawn failures
640
+ // surface via console.warn but never fail the /reset call.
641
+ api.post('/demos/:id/reset', async (c) => {
642
+ const id = c.req.param('id');
643
+ const entry = registry.getById(id);
644
+ if (!entry) return c.json({ error: 'unknown demo' }, 404);
645
+ if (!events) return c.json({ error: 'events not enabled' }, 500);
646
+
647
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
648
+ if (!existsSync(fullPath)) {
649
+ return c.json({ error: `Demo file not found: ${fullPath}` }, 404);
650
+ }
651
+ let raw: unknown;
652
+ try {
653
+ raw = await Bun.file(fullPath).json();
654
+ } catch (err) {
655
+ return c.json(
656
+ {
657
+ error: `Demo file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
658
+ },
659
+ 400,
660
+ );
661
+ }
662
+ const parsed = DemoSchema.safeParse(raw);
663
+ if (!parsed.success) {
664
+ return c.json({ error: 'Demo failed schema validation', issues: parsed.error.issues }, 400);
665
+ }
666
+
667
+ // 1. Stop every play + status script in parallel. await BOTH before
668
+ // spawning the reset script so a still-running play can't race the
669
+ // reset and re-dirty the running app's state.
670
+ const stopPromises: Array<Promise<void>> = [proxy.stopAllPlays(id)];
671
+ if (statusRunner) stopPromises.push(statusRunner.stop(id));
672
+ await Promise.all(stopPromises);
673
+
674
+ // 2. Run resetAction (if declared).
675
+ const resetAction = parsed.data.resetAction;
676
+ let calledResetAction = false;
677
+ let resetActionError: string | undefined;
678
+
679
+ if (resetAction) {
680
+ calledResetAction = true;
681
+ const result = await proxy.runReset({
682
+ events,
683
+ demoId: id,
684
+ cwd: entry.repoPath,
685
+ action: resetAction,
686
+ });
687
+ if (!result.ok && result.error) {
688
+ resetActionError = result.error;
689
+ }
690
+ }
691
+
692
+ // 3. Broadcast reload unconditionally — even when resetAction failed,
693
+ // the canvas should still refresh from disk in case the user just
694
+ // edited the file.
695
+ events.broadcast({
696
+ type: 'demo:reload',
697
+ demoId: id,
698
+ payload: {},
699
+ });
700
+
701
+ // 4. Fire-and-forget the next status batch.
702
+ if (statusRunner) {
703
+ void statusRunner.restart(id).catch((err) => {
704
+ console.warn(
705
+ `[api] statusRunner.restart(${id}) failed: ${err instanceof Error ? err.message : String(err)}`,
706
+ );
707
+ });
708
+ }
709
+
710
+ if (resetActionError) {
711
+ return c.json({ error: resetActionError, calledResetAction }, 502);
712
+ }
713
+ return c.json({ ok: true, calledResetAction });
714
+ });
715
+
716
+ // PATCH a single node's position back into the on-disk seeflow.json. This is
717
+ // the second (and only other) place the studio mutates user files — the
718
+ // first being the SDK helper write in `register`. Atomic write via tempfile
719
+ // + rename keeps editor diffs clean and avoids corruption mid-write.
720
+ api.patch('/demos/:id/nodes/:nodeId/position', async (c) => {
721
+ const id = c.req.param('id');
722
+ const nodeId = c.req.param('nodeId');
723
+
724
+ let body: unknown;
725
+ try {
726
+ body = await c.req.json();
727
+ } catch {
728
+ return c.json({ error: 'Body must be valid JSON' }, 400);
729
+ }
730
+ const parsed = PositionBodySchema.safeParse(body);
731
+ if (!parsed.success) {
732
+ return c.json({ error: 'Invalid position body', issues: parsed.error.issues }, 400);
733
+ }
734
+
735
+ const result = await moveNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
736
+ switch (result.kind) {
737
+ case 'ok':
738
+ return c.json({ ok: true, position: result.data.position });
739
+ case 'demoNotFound':
740
+ return c.json({ error: 'unknown demo' }, 404);
741
+ case 'fileNotFound':
742
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
743
+ case 'badJson':
744
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
745
+ case 'badSchema':
746
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
747
+ case 'unknownNode':
748
+ return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
749
+ case 'writeFailed':
750
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
751
+ }
752
+ });
753
+
754
+ // PATCH the z-order position of a single node within demo.nodes[]. React
755
+ // Flow's painter renders nodes in array order, so moving a node to a later
756
+ // index brings it visually forward (later nodes paint over earlier ones).
757
+ // Five ops are supported: forward / backward (single-step swap), toFront /
758
+ // toBack (remove + push/unshift), and toIndex (pin to an absolute index)
759
+ // which the undo path uses to faithfully revert forward/backward gestures
760
+ // even if the array changed between the original op and the undo.
761
+ api.patch('/demos/:id/nodes/:nodeId/order', async (c) => {
762
+ const id = c.req.param('id');
763
+ const nodeId = c.req.param('nodeId');
764
+
765
+ let body: unknown;
766
+ try {
767
+ body = await c.req.json();
768
+ } catch {
769
+ return c.json({ error: 'Body must be valid JSON' }, 400);
770
+ }
771
+ const parsed = ReorderBodySchema.safeParse(body);
772
+ if (!parsed.success) {
773
+ return c.json({ error: 'Invalid reorder body', issues: parsed.error.issues }, 400);
774
+ }
775
+
776
+ const result = await reorderNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
777
+ switch (result.kind) {
778
+ case 'ok':
779
+ return c.json({ ok: true });
780
+ case 'demoNotFound':
781
+ return c.json({ error: 'unknown demo' }, 404);
782
+ case 'fileNotFound':
783
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
784
+ case 'badJson':
785
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
786
+ case 'badSchema':
787
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
788
+ case 'unknownNode':
789
+ return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
790
+ case 'writeFailed':
791
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
792
+ }
793
+ });
794
+
795
+ // PATCH a single node — partial update of position, label, detail, visual
796
+ // fields, or shapeNode-only fields. Every UI-driven node edit (other than
797
+ // the high-frequency drag fast-path above) flows through here. The mutation
798
+ // is performed against the raw parsed JSON (so unknown v2 fields the schema
799
+ // doesn't yet recognize survive round-trips) and the WHOLE resulting demo
800
+ // is re-validated through DemoSchema before commit, preventing partial
801
+ // writes from breaking invariants like the connector→node superRefine.
802
+ api.patch('/demos/:id/nodes/:nodeId', async (c) => {
803
+ const id = c.req.param('id');
804
+ const nodeId = c.req.param('nodeId');
805
+
806
+ let body: unknown;
807
+ try {
808
+ body = await c.req.json();
809
+ } catch {
810
+ return c.json({ error: 'Body must be valid JSON' }, 400);
811
+ }
812
+ const parsed = NodePatchBodySchema.safeParse(body);
813
+ if (!parsed.success) {
814
+ return c.json({ error: 'Invalid node patch body', issues: parsed.error.issues }, 400);
815
+ }
816
+
817
+ const result = await patchNodeImpl({ registry, watcher }, id, nodeId, parsed.data);
818
+ switch (result.kind) {
819
+ case 'ok':
820
+ return c.json({ ok: true });
821
+ case 'demoNotFound':
822
+ return c.json({ error: 'unknown demo' }, 404);
823
+ case 'fileNotFound':
824
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
825
+ case 'badJson':
826
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
827
+ case 'badSchema':
828
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
829
+ case 'unknownNode':
830
+ return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
831
+ case 'writeFailed':
832
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
833
+ }
834
+ });
835
+
836
+ // POST a new node into the demo. Body is the node payload (id auto-generated
837
+ // server-side if absent). Atomicity + final-DemoSchema validation match the
838
+ // PATCH path above, so a malformed node never produces a half-written file.
839
+ api.post('/demos/:id/nodes', async (c) => {
840
+ const id = c.req.param('id');
841
+
842
+ let body: unknown;
843
+ try {
844
+ body = await c.req.json();
845
+ } catch {
846
+ return c.json({ error: 'Body must be valid JSON' }, 400);
847
+ }
848
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
849
+ return c.json({ error: 'Body must be an object' }, 400);
850
+ }
851
+
852
+ const result = await addNodeImpl({ registry, watcher }, id, body as Record<string, unknown>);
853
+ switch (result.kind) {
854
+ case 'ok':
855
+ return c.json({ ok: true, id: result.data.id, node: result.data.node });
856
+ case 'demoNotFound':
857
+ return c.json({ error: 'unknown demo' }, 404);
858
+ case 'fileNotFound':
859
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
860
+ case 'badJson':
861
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
862
+ case 'badSchema':
863
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
864
+ case 'writeFailed':
865
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
866
+ }
867
+ });
868
+
869
+ // DELETE a node and cascade-remove every connector with source === nodeId or
870
+ // target === nodeId in the same atomic write. Final-DemoSchema validation
871
+ // is still run after the mutation — connector cascade closure means it
872
+ // should always pass, but the check makes the failure mode honest if the
873
+ // file had a pre-existing schema violation we'd otherwise paper over.
874
+ api.delete('/demos/:id/nodes/:nodeId', async (c) => {
875
+ const id = c.req.param('id');
876
+ const nodeId = c.req.param('nodeId');
877
+
878
+ const result = await deleteNodeImpl({ registry, watcher }, id, nodeId);
879
+ switch (result.kind) {
880
+ case 'ok':
881
+ return c.json({ ok: true });
882
+ case 'demoNotFound':
883
+ return c.json({ error: 'unknown demo' }, 404);
884
+ case 'fileNotFound':
885
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
886
+ case 'badJson':
887
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
888
+ case 'badSchema':
889
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
890
+ case 'unknownNode':
891
+ return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
892
+ case 'writeFailed':
893
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
894
+ }
895
+ });
896
+
897
+ // PATCH a single connector — partial update of label/style/color/direction
898
+ // and (optionally) kind + per-kind payload fields. When `kind` changes,
899
+ // stale kind-specific fields are dropped before the merge. The whole demo
900
+ // is re-validated through DemoSchema before commit so the discriminated
901
+ // union catches missing-required-fields (e.g. kind='event' without
902
+ // eventName) and the superRefine still gates source/target referential
903
+ // integrity.
904
+ api.patch('/demos/:id/connectors/:connId', async (c) => {
905
+ const id = c.req.param('id');
906
+ const connId = c.req.param('connId');
907
+
908
+ let body: unknown;
909
+ try {
910
+ body = await c.req.json();
911
+ } catch {
912
+ return c.json({ error: 'Body must be valid JSON' }, 400);
913
+ }
914
+ const parsed = ConnectorPatchBodySchema.safeParse(body);
915
+ if (!parsed.success) {
916
+ return c.json({ error: 'Invalid connector patch body', issues: parsed.error.issues }, 400);
917
+ }
918
+
919
+ const result = await patchConnectorImpl({ registry, watcher }, id, connId, parsed.data);
920
+ switch (result.kind) {
921
+ case 'ok':
922
+ return c.json({ ok: true });
923
+ case 'demoNotFound':
924
+ return c.json({ error: 'unknown demo' }, 404);
925
+ case 'fileNotFound':
926
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
927
+ case 'badJson':
928
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
929
+ case 'badSchema':
930
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
931
+ case 'unknownConnector':
932
+ return c.json({ error: `Unknown connectorId: ${connId}` }, 404);
933
+ case 'writeFailed':
934
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
935
+ }
936
+ });
937
+
938
+ // POST a new connector. Body is the connector payload; `id` is auto-generated
939
+ // server-side if absent and `kind` defaults to 'default' (the no-semantics
940
+ // user-drawn variant). Source/target referential integrity is enforced by
941
+ // DemoSchema's superRefine on the post-mutation parse.
942
+ api.post('/demos/:id/connectors', async (c) => {
943
+ const id = c.req.param('id');
944
+
945
+ let body: unknown;
946
+ try {
947
+ body = await c.req.json();
948
+ } catch {
949
+ return c.json({ error: 'Body must be valid JSON' }, 400);
950
+ }
951
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
952
+ return c.json({ error: 'Body must be an object' }, 400);
953
+ }
954
+
955
+ const result = await addConnectorImpl(
956
+ { registry, watcher },
957
+ id,
958
+ body as Record<string, unknown>,
959
+ );
960
+ switch (result.kind) {
961
+ case 'ok':
962
+ return c.json({ ok: true, id: result.data.id });
963
+ case 'demoNotFound':
964
+ return c.json({ error: 'unknown demo' }, 404);
965
+ case 'fileNotFound':
966
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
967
+ case 'badJson':
968
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
969
+ case 'badSchema':
970
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
971
+ case 'writeFailed':
972
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
973
+ }
974
+ });
975
+
976
+ // DELETE a connector. Just removes the entry from demo.connectors — node
977
+ // deletion is what cascades, not connector deletion.
978
+ api.delete('/demos/:id/connectors/:connId', async (c) => {
979
+ const id = c.req.param('id');
980
+ const connId = c.req.param('connId');
981
+
982
+ const result = await deleteConnectorImpl({ registry, watcher }, id, connId);
983
+ switch (result.kind) {
984
+ case 'ok':
985
+ return c.json({ ok: true });
986
+ case 'demoNotFound':
987
+ return c.json({ error: 'unknown demo' }, 404);
988
+ case 'fileNotFound':
989
+ return c.json({ error: `Demo file not found: ${result.path}` }, 404);
990
+ case 'badJson':
991
+ return c.json({ error: `Demo file is not valid JSON: ${result.message}` }, 400);
992
+ case 'badSchema':
993
+ return c.json({ error: 'Demo failed schema validation', issues: result.issues }, 400);
994
+ case 'unknownConnector':
995
+ return c.json({ error: `Unknown connectorId: ${connId}` }, 404);
996
+ case 'writeFailed':
997
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
998
+ }
999
+ });
1000
+
1001
+ api.post('/emit', async (c) => {
1002
+ if (!events) return c.json({ error: 'events not enabled' }, 500);
1003
+
1004
+ let body: unknown;
1005
+ try {
1006
+ body = await c.req.json();
1007
+ } catch {
1008
+ return c.json({ error: 'Body must be valid JSON' }, 400);
1009
+ }
1010
+
1011
+ const parsed = EmitBodySchema.safeParse(body);
1012
+ if (!parsed.success) {
1013
+ return c.json({ error: 'Invalid emit body', issues: parsed.error.issues }, 400);
1014
+ }
1015
+
1016
+ const { demoId, nodeId, status, runId, payload } = parsed.data;
1017
+ if (!registry.getById(demoId)) {
1018
+ return c.json({ error: `Unknown demoId: ${demoId}` }, 404);
1019
+ }
1020
+
1021
+ const extras =
1022
+ payload && typeof payload === 'object' && !Array.isArray(payload)
1023
+ ? (payload as Record<string, unknown>)
1024
+ : {};
1025
+ const eventPayload: Record<string, unknown> = { nodeId, ...extras };
1026
+ if (runId !== undefined) eventPayload.runId = runId;
1027
+
1028
+ events.broadcast({
1029
+ type: EMIT_STATUS_TO_EVENT[status],
1030
+ demoId,
1031
+ payload: eventPayload,
1032
+ });
1033
+
1034
+ return c.json({ ok: true });
1035
+ });
1036
+
1037
+ api.get('/events', (c) => {
1038
+ const demoId = c.req.query('demoId');
1039
+ if (!demoId) return c.json({ error: 'demoId query param required' }, 400);
1040
+ if (!registry.getById(demoId)) return c.json({ error: 'unknown demoId' }, 404);
1041
+ if (!events) return c.json({ error: 'events not enabled' }, 500);
1042
+
1043
+ return streamSSE(c, async (stream) => {
1044
+ let active = true;
1045
+ const queue: Array<{ event: string; data: string }> = [];
1046
+ let resume: (() => void) | null = null;
1047
+
1048
+ const wake = () => {
1049
+ if (resume) {
1050
+ const r = resume;
1051
+ resume = null;
1052
+ r();
1053
+ }
1054
+ };
1055
+
1056
+ const unsubscribe = events.subscribe(demoId, (e) => {
1057
+ queue.push({ event: e.type, data: JSON.stringify({ ts: e.ts, ...(e.payload as object) }) });
1058
+ wake();
1059
+ });
1060
+
1061
+ stream.onAbort(() => {
1062
+ active = false;
1063
+ unsubscribe();
1064
+ wake();
1065
+ });
1066
+
1067
+ // Initial 'hello' so reconnecting clients can confirm the stream is open
1068
+ // and trigger a re-fetch on the frontend.
1069
+ await stream.writeSSE({
1070
+ event: 'hello',
1071
+ data: JSON.stringify({ demoId, ts: Date.now() }),
1072
+ });
1073
+
1074
+ try {
1075
+ while (active) {
1076
+ while (queue.length > 0) {
1077
+ const next = queue.shift();
1078
+ if (!next) break;
1079
+ await stream.writeSSE(next);
1080
+ }
1081
+ if (!active) break;
1082
+ await new Promise<void>((r) => {
1083
+ resume = r;
1084
+ });
1085
+ }
1086
+ } finally {
1087
+ unsubscribe();
1088
+ }
1089
+ });
1090
+ });
1091
+
1092
+ return api;
1093
+ }