@tuongaz/seeflow 0.1.77 → 0.1.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +40 -0
  2. package/dist/web/assets/{architectureDiagram-3BPJPVTR-D5iHwVvy.js → architectureDiagram-3BPJPVTR-BfABufg7.js} +1 -1
  3. package/dist/web/assets/{blockDiagram-GPEHLZMM-MAYYm7FM.js → blockDiagram-GPEHLZMM-DEsJzjMp.js} +1 -1
  4. package/dist/web/assets/{c4Diagram-AAUBKEIU-7P7yfHg1.js → c4Diagram-AAUBKEIU-BI2bLAFy.js} +1 -1
  5. package/dist/web/assets/channel-C4GDf2FG.js +1 -0
  6. package/dist/web/assets/{chart-C68vupBE.js → chart-BbL2c3JQ.js} +1 -1
  7. package/dist/web/assets/{chunk-2J33WTMH-Bb4cSusI.js → chunk-2J33WTMH-Cgomd5cT.js} +1 -1
  8. package/dist/web/assets/{chunk-4BX2VUAB-DXYpcpTh.js → chunk-4BX2VUAB-D7PhkaW3.js} +1 -1
  9. package/dist/web/assets/{chunk-55IACEB6-BxuYKDnf.js → chunk-55IACEB6-CUFJz27b.js} +1 -1
  10. package/dist/web/assets/{chunk-727SXJPM-DbWlxAr2.js → chunk-727SXJPM-DuhsbPQs.js} +1 -1
  11. package/dist/web/assets/{chunk-AQP2D5EJ-DT8S1q80.js → chunk-AQP2D5EJ-1wXXetBT.js} +1 -1
  12. package/dist/web/assets/{chunk-FMBD7UC4-Dc0wDuZz.js → chunk-FMBD7UC4-D6ESudu6.js} +1 -1
  13. package/dist/web/assets/{chunk-ND2GUHAM-CqLLK6H0.js → chunk-ND2GUHAM-Dq8nvkVX.js} +1 -1
  14. package/dist/web/assets/{chunk-QZHKN3VN-CxF7nkDI.js → chunk-QZHKN3VN-2m-ARThf.js} +1 -1
  15. package/dist/web/assets/classDiagram-4FO5ZUOK-DxP4Rh0o.js +1 -0
  16. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DxP4Rh0o.js +1 -0
  17. package/dist/web/assets/{code-block-DR9fiK_U.js → code-block-DTN7FEQj.js} +1 -1
  18. package/dist/web/assets/{cose-bilkent-S5V4N54A-BflFbtY2.js → cose-bilkent-S5V4N54A-DjzT5K-0.js} +1 -1
  19. package/dist/web/assets/{dagre-BM42HDAG-BJ5UdyYS.js → dagre-BM42HDAG-BwzsmtSu.js} +1 -1
  20. package/dist/web/assets/{diagram-2AECGRRQ-D0M8fCf7.js → diagram-2AECGRRQ-FYQyXaT_.js} +1 -1
  21. package/dist/web/assets/{diagram-5GNKFQAL-D67gAMS4.js → diagram-5GNKFQAL-DAzyREi8.js} +1 -1
  22. package/dist/web/assets/{diagram-KO2AKTUF-XX62HBG-.js → diagram-KO2AKTUF-DGKrMcjo.js} +1 -1
  23. package/dist/web/assets/{diagram-LMA3HP47-DCFq3Oac.js → diagram-LMA3HP47-Bff209n5.js} +1 -1
  24. package/dist/web/assets/{diagram-OG6HWLK6-Be392NCN.js → diagram-OG6HWLK6-BuqayNQm.js} +1 -1
  25. package/dist/web/assets/{erDiagram-TEJ5UH35-DP4eP0as.js → erDiagram-TEJ5UH35-BUjs8hUi.js} +1 -1
  26. package/dist/web/assets/{flowDiagram-I6XJVG4X-Ch1GVJ9R.js → flowDiagram-I6XJVG4X-B_D_x2FB.js} +1 -1
  27. package/dist/web/assets/{ganttDiagram-6RSMTGT7-DtvkTizu.js → ganttDiagram-6RSMTGT7-DxfI1q3k.js} +1 -1
  28. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-YGcuBgb9.js → gitGraphDiagram-PVQCEYII-BIEMbeDK.js} +1 -1
  29. package/dist/web/assets/index-DRyzDJUb.js +8619 -0
  30. package/dist/web/assets/{index-DljfurDC.css → index-fl8DS9WO.css} +1 -1
  31. package/dist/web/assets/{index.es-jrsJPbYZ.js → index.es-CiKqWLXZ.js} +1 -1
  32. package/dist/web/assets/{infoDiagram-5YYISTIA-wce0BORz.js → infoDiagram-5YYISTIA-DdStjmcL.js} +1 -1
  33. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-u2MvPgdW.js → ishikawaDiagram-YF4QCWOH-1YlL8XhA.js} +1 -1
  34. package/dist/web/assets/{journeyDiagram-JHISSGLW-BsOyrTiA.js → journeyDiagram-JHISSGLW-4UmpNnmE.js} +1 -1
  35. package/dist/web/assets/{jspdf.es.min-ptMERvnN.js → jspdf.es.min-CfPjY_mb.js} +3 -3
  36. package/dist/web/assets/{kanban-definition-UN3LZRKU-BaraYV9q.js → kanban-definition-UN3LZRKU-BpHur4yM.js} +1 -1
  37. package/dist/web/assets/{linear-BVqXcDUJ.js → linear-BlKd__8P.js} +1 -1
  38. package/dist/web/assets/{markdown-DqP0Cywq.js → markdown-B0s014Jt.js} +1 -1
  39. package/dist/web/assets/{mermaid.core-CakR_vo1.js → mermaid.core-C1P8oPqR.js} +4 -4
  40. package/dist/web/assets/{mindmap-definition-RKZ34NQL-CO5AsZw3.js → mindmap-definition-RKZ34NQL-CbYEKJD0.js} +1 -1
  41. package/dist/web/assets/{pieDiagram-4H26LBE5-CiDJY-kx.js → pieDiagram-4H26LBE5-DTM5C3b_.js} +1 -1
  42. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BS6oN3s_.js → quadrantDiagram-W4KKPZXB-BTLv_k9D.js} +1 -1
  43. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNbUR_FF.js → requirementDiagram-4Y6WPE33-xrt0GAFn.js} +1 -1
  44. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-0Esj5uzm.js → sankeyDiagram-5OEKKPKP-CTC7QJzV.js} +1 -1
  45. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DR3U38Zi.js → sequenceDiagram-3UESZ5HK-DXxHxv0c.js} +1 -1
  46. package/dist/web/assets/{stateDiagram-AJRCARHV-C50RQjWe.js → stateDiagram-AJRCARHV-Cq8S_qAw.js} +1 -1
  47. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BI8aEZqM.js +1 -0
  48. package/dist/web/assets/{time-C_2J9tFX.js → time-BWlQ2zTb.js} +1 -1
  49. package/dist/web/assets/{timeline-definition-PNZ67QCA-BQXyo2r_.js → timeline-definition-PNZ67QCA-ooIJpgWs.js} +1 -1
  50. package/dist/web/assets/{vennDiagram-CIIHVFJN-DZJ8M3EA.js → vennDiagram-CIIHVFJN-ClAQa-gM.js} +1 -1
  51. package/dist/web/assets/{wardley-L42UT6IY-B96HtW3i.js → wardley-L42UT6IY-UjncjFg7.js} +1 -1
  52. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BHkQ79WC.js → wardleyDiagram-YWT4CUSO-BQkPAPTU.js} +1 -1
  53. package/dist/web/assets/{xychartDiagram-2RQKCTM6-B_f8koGI.js → xychartDiagram-2RQKCTM6-CO6j1v0J.js} +1 -1
  54. package/dist/web/index.html +2 -2
  55. package/examples/component-showcase/seeflow.json +6 -0
  56. package/examples/ecommerce-platform/seeflow.json +6 -0
  57. package/examples/order-pipeline/seeflow.json +6 -0
  58. package/package.json +1 -1
  59. package/src/api.ts +739 -94
  60. package/src/cli-e2e.ts +24 -13
  61. package/src/cli-helpers.ts +26 -0
  62. package/src/cli-manifest.ts +330 -87
  63. package/src/cli-ops.ts +56 -2
  64. package/src/cli.ts +228 -81
  65. package/src/cors.ts +93 -0
  66. package/src/jq-filter.ts +253 -0
  67. package/src/mcp-shim.ts +114 -7
  68. package/src/mcp-ui.ts +126 -0
  69. package/src/mcp.ts +258 -97
  70. package/src/node-files.ts +18 -7
  71. package/src/operations.ts +68 -32
  72. package/src/project-scanner.ts +105 -0
  73. package/src/registry.ts +79 -18
  74. package/src/route-resolve.ts +41 -0
  75. package/src/schema.ts +54 -0
  76. package/src/server.ts +24 -3
  77. package/src/slugify.ts +16 -0
  78. package/dist/web/assets/channel-BjsQQK93.js +0 -1
  79. package/dist/web/assets/classDiagram-4FO5ZUOK-p3FY5uNC.js +0 -1
  80. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-p3FY5uNC.js +0 -1
  81. package/dist/web/assets/index-Bg3PU4Ev.js +0 -8614
  82. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BbNrmkIR.js +0 -1
  83. /package/examples/component-showcase/{flow.json → flows/main/flow.json} +0 -0
  84. /package/examples/component-showcase/{nodes → flows/main/nodes}/chart/spec.json +0 -0
  85. /package/examples/component-showcase/{nodes → flows/main/nodes}/counter/spec.json +0 -0
  86. /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/actions/refresh.ts +0 -0
  87. /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/spec.json +0 -0
  88. /package/examples/component-showcase/{nodes → flows/main/nodes}/form/spec.json +0 -0
  89. /package/examples/component-showcase/{style.json → flows/main/style.json} +0 -0
  90. /package/examples/ecommerce-platform/{flow.json → flows/main/flow.json} +0 -0
  91. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-3zFtHg6ENc/detail.md +0 -0
  92. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-5F424NWbEu/detail.md +0 -0
  93. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-CbwYqb7NfB/detail.md +0 -0
  94. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-XwygzfKPZ5/view.html +0 -0
  95. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-fkptXw7uvs/detail.md +0 -0
  96. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-kwBY8YPmYM/detail.md +0 -0
  97. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-mPqan8rFYN/detail.md +0 -0
  98. /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-yKrg9DV5fJ/detail.md +0 -0
  99. /package/examples/ecommerce-platform/{scripts → flows/main/scripts}/play.ts +0 -0
  100. /package/examples/ecommerce-platform/{style.json → flows/main/style.json} +0 -0
  101. /package/examples/order-pipeline/{flow.json → flows/main/flow.json} +0 -0
  102. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-GXTKUcE3ye/detail.md +0 -0
  103. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-XKIyds0TDg/detail.md +0 -0
  104. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-YOYiHJpY0i/detail.md +0 -0
  105. /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-zUIH7WFnhK/detail.md +0 -0
  106. /package/examples/order-pipeline/{scripts → flows/main/scripts}/play.ts +0 -0
  107. /package/examples/order-pipeline/{style.json → flows/main/style.json} +0 -0
package/src/api.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import { streamSSE } from 'hono/streaming';
5
5
  import { z } from 'zod';
6
+ import { registerProject } from './cli-ops.ts';
6
7
  import { runComponentAction } from './component-action-runner.ts';
7
8
  import {
8
9
  AssembleRequestSchema,
@@ -37,10 +38,11 @@ import {
37
38
  runReset as defaultRunReset,
38
39
  stopAllPlays as defaultStopAllPlays,
39
40
  } from './proxy.ts';
40
- import type { Registry } from './registry.ts';
41
+ import type { FlowEntry, Registry } from './registry.ts';
42
+ import { resolveProjectFlow } from './route-resolve.ts';
41
43
  import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
42
- import type { ComponentAction } from './schema.ts';
43
- import { FlowSchema, ResolvedFlowSchema } from './schema.ts';
44
+ import type { ComponentAction, SeeflowManifest } from './schema.ts';
45
+ import { FlowIdPattern, FlowSchema, ResolvedFlowSchema, SeeflowManifestSchema } from './schema.ts';
44
46
  import { type Spawner, defaultSpawner } from './shellout.ts';
45
47
  import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
46
48
  import type { StatusRunner } from './status-runner.ts';
@@ -55,6 +57,37 @@ const EmitBodySchema = z.object({
55
57
  payload: z.unknown().optional(),
56
58
  });
57
59
 
60
+ // Body for POST /api/projects/:project/flows (US-015). The flow id reuses
61
+ // FlowIdPattern from schema.ts — same constraint the seeflow.json manifest
62
+ // enforces, so a manifest round-trip after this route runs cannot fail
63
+ // validation on the new entry's id.
64
+ const CreateFlowBodySchema = z.object({
65
+ id: z.string().regex(FlowIdPattern, {
66
+ message: 'flow id must match /^[a-z0-9][a-z0-9-]*$/',
67
+ }),
68
+ name: z.string().min(1),
69
+ icon: z.string().min(1).optional(),
70
+ });
71
+
72
+ // Body for PATCH /api/projects/:project/flows/:flow (US-016). All three
73
+ // fields are optional; the .refine() rejects an empty body so callers can't
74
+ // no-op the route. The same FlowIdPattern that guards POST guards id renames
75
+ // here, so a renamed flow's id stays manifest-compatible.
76
+ const PatchFlowBodySchema = z
77
+ .object({
78
+ id: z
79
+ .string()
80
+ .regex(FlowIdPattern, {
81
+ message: 'flow id must match /^[a-z0-9][a-z0-9-]*$/',
82
+ })
83
+ .optional(),
84
+ name: z.string().min(1).optional(),
85
+ icon: z.string().min(1).optional(),
86
+ })
87
+ .refine((b) => b.id !== undefined || b.name !== undefined || b.icon !== undefined, {
88
+ message: 'body must include at least one of id, name, icon',
89
+ });
90
+
58
91
  type RelativePathCheck = { kind: 'ok' } | { kind: 'invalid'; reason: string };
59
92
 
60
93
  // Reject absolute paths and `..` traversal before any filesystem touch.
@@ -91,12 +124,16 @@ type ResolvedProjectFile =
91
124
  // (defense against symlink escapes). Returns the realpath of an existing file
92
125
  // on success, or `fileMissing` with the would-be absolute path so callers can
93
126
  // soft-fail with that path included for clipboard fallback.
127
+ //
128
+ // Project addressing is by `projectSlug` (post-US-008): the first registry
129
+ // entry whose `projectSlug` matches supplies `repoPath` since every entry in
130
+ // a project shares the same on-disk root.
94
131
  function resolveProjectFile(
95
132
  registry: Registry,
96
- projectId: string,
133
+ projectSlug: string,
97
134
  relPath: string,
98
135
  ): ResolvedProjectFile {
99
- const entry = registry.getById(projectId);
136
+ const entry = registry.list().find((e) => e.projectSlug === projectSlug);
100
137
  if (!entry) return { kind: 'unknownProject' };
101
138
 
102
139
  const guard = validateRelativePath(relPath);
@@ -126,6 +163,22 @@ function resolveProjectFile(
126
163
  return { kind: 'ok', absPath: realTarget, projectRoot: realRoot };
127
164
  }
128
165
 
166
+ // Read + validate `<repoPath>/seeflow.json` for the project listing routes.
167
+ // Returns `null` for missing or malformed manifests so callers can fall back
168
+ // to derived defaults (projectSlug + isDefault entry) instead of failing the
169
+ // whole listing on one bad project.
170
+ function readProjectManifest(repoPath: string): SeeflowManifest | null {
171
+ const manifestPath = join(repoPath, 'seeflow.json');
172
+ if (!existsSync(manifestPath)) return null;
173
+ try {
174
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
175
+ const parsed = SeeflowManifestSchema.safeParse(raw);
176
+ return parsed.success ? parsed.data : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
129
182
  // Allowed extensions for /nodes/:nodeId/files/upload. Lowercased; matched after dropping the
130
183
  // leading `.`. Stored as a Set so future expansion (PDF, video) is one-edit.
131
184
  const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
@@ -222,6 +275,9 @@ export function createApi(options: ApiOptions): Hono {
222
275
  }
223
276
 
224
277
  const result = await ops.registerFlow(parsed.data);
278
+ if (result.kind === 'ok') {
279
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
280
+ }
225
281
  switch (result.kind) {
226
282
  case 'ok':
227
283
  return c.json(result.data);
@@ -234,6 +290,42 @@ export function createApi(options: ApiOptions): Hono {
234
290
  }
235
291
  });
236
292
 
293
+ // POST /api/projects/register — manifest-driven registration. Reads
294
+ // <repoPath>/seeflow.json + walks declared flows under flows/<id>/flow.json,
295
+ // upserting one FlowEntry per declared flow with the manifest's name +
296
+ // per-flow names (vs. /api/flows/register which is the legacy single-flow
297
+ // path that uses the same name for both project and flow).
298
+ api.post('/projects/register', async (c) => {
299
+ let body: unknown;
300
+ try {
301
+ body = await c.req.json();
302
+ } catch {
303
+ return c.json({ error: 'Body must be valid JSON' }, 400);
304
+ }
305
+ const parsed = z.object({ repoPath: z.string().min(1) }).safeParse(body);
306
+ if (!parsed.success) {
307
+ return c.json({ error: 'Invalid register body', issues: parsed.error.issues }, 400);
308
+ }
309
+ const result = registerProject({ repoPath: parsed.data.repoPath, registry });
310
+ if (result.kind === 'ok') {
311
+ for (const entry of result.entries) watcher?.watch(entry.id);
312
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
313
+ return c.json({
314
+ ok: true as const,
315
+ projectSlug: result.projectSlug,
316
+ entries: result.entries.map((e) => ({
317
+ id: e.id,
318
+ slug: e.slug,
319
+ projectSlug: e.projectSlug,
320
+ flowSlug: e.flowSlug,
321
+ name: e.name,
322
+ isDefault: e.isDefault,
323
+ })),
324
+ });
325
+ }
326
+ return c.json({ ok: false as const, error: result.kind }, 400);
327
+ });
328
+
237
329
  // POST /api/flows/validate — dry-run validation. The skill's diagram
238
330
  // pipeline calls this between assemble and register to decide whether to
239
331
  // rewire. Runs the Zod schema, the soft node cap, and the tier playability
@@ -498,8 +590,399 @@ export function createApi(options: ApiOptions): Hono {
498
590
  return c.json(result.data);
499
591
  });
500
592
 
501
- api.get('/flows/:id', async (c) => {
502
- const result = await ops.getFlow(c.req.param('id'));
593
+ // GET /api/projects projects landing index. Groups registry.list() by
594
+ // projectSlug and reads each project's `seeflow.json` for the human-readable
595
+ // name + defaultFlow. When the manifest is missing or malformed, falls back
596
+ // to the projectSlug for name and the flowSlug of the registry's
597
+ // `isDefault: true` entry for defaultFlow — so a partially-broken project
598
+ // still surfaces in the picker.
599
+ api.get('/projects', (c) => {
600
+ const grouped = new Map<string, FlowEntry[]>();
601
+ for (const entry of registry.list()) {
602
+ const existing = grouped.get(entry.projectSlug);
603
+ if (existing) {
604
+ existing.push(entry);
605
+ } else {
606
+ grouped.set(entry.projectSlug, [entry]);
607
+ }
608
+ }
609
+ const projects: Array<{
610
+ projectSlug: string;
611
+ name: string;
612
+ description?: string;
613
+ defaultFlow: string;
614
+ flowCount: number;
615
+ repoPath: string;
616
+ }> = [];
617
+ for (const [projectSlug, entries] of grouped) {
618
+ const head = entries[0];
619
+ if (!head) continue;
620
+ const manifest = readProjectManifest(head.repoPath);
621
+ const defaultEntry = entries.find((e) => e.isDefault) ?? head;
622
+ projects.push({
623
+ projectSlug,
624
+ name: manifest?.name ?? projectSlug,
625
+ description: manifest?.description,
626
+ defaultFlow: manifest?.defaultFlow ?? defaultEntry.flowSlug,
627
+ flowCount: entries.length,
628
+ repoPath: head.repoPath,
629
+ });
630
+ }
631
+ return c.json({ projects });
632
+ });
633
+
634
+ // GET /api/projects/:project — per-project metadata + flow entries. 404s
635
+ // with `project-not-found` when no registry entry shares the slug — same
636
+ // shape the flow-scoped routes (US-007) use for resolution failures, so
637
+ // clients have a single error pattern across the projects/* tree. The
638
+ // manifest read is best-effort (missing/malformed → null) so the route
639
+ // never depends on disk state for the slug check itself.
640
+ api.get('/projects/:project', (c) => {
641
+ const projectSlug = c.req.param('project');
642
+ const flows = registry.list().filter((e) => e.projectSlug === projectSlug);
643
+ const head = flows[0];
644
+ if (!head) {
645
+ return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
646
+ }
647
+ const manifest = readProjectManifest(head.repoPath);
648
+ const defaultEntry = flows.find((e) => e.isDefault) ?? head;
649
+ return c.json({
650
+ projectSlug,
651
+ name: manifest?.name ?? projectSlug,
652
+ description: manifest?.description,
653
+ defaultFlow: manifest?.defaultFlow ?? defaultEntry.flowSlug,
654
+ flows,
655
+ });
656
+ });
657
+
658
+ // GET /api/projects/:project/flows — per-project flow listing. Powers the
659
+ // canvas page's Figma-style flow switcher popover (US-024). Returns the
660
+ // narrow shape the picker needs — id, flowSlug, name, icon, isDefault —
661
+ // rather than the full FlowEntry; clients that need repoPath/flowPath go
662
+ // through `GET /api/projects/:project` instead. 404s with `project-not-found`
663
+ // when no registry entry shares the slug (same shape US-007 + the
664
+ // GET /api/projects/:project route above use for resolution failures).
665
+ api.get('/projects/:project/flows', (c) => {
666
+ const projectSlug = c.req.param('project');
667
+ const entries = registry.list().filter((e) => e.projectSlug === projectSlug);
668
+ if (entries.length === 0) {
669
+ return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
670
+ }
671
+ const flows = entries.map((e) => ({
672
+ id: e.id,
673
+ flowSlug: e.flowSlug,
674
+ name: e.name,
675
+ icon: e.icon,
676
+ isDefault: e.isDefault,
677
+ }));
678
+ return c.json({ flows });
679
+ });
680
+
681
+ // POST /api/projects/:project/flows — create a new flow within an existing
682
+ // project (US-015). Atomically: write `flows/<id>/flow.json` with an empty
683
+ // envelope → append the new entry to `seeflow.json` → `registry.upsert()`.
684
+ // If the manifest write fails after the flow folder is on disk, the folder
685
+ // is removed so the project state stays consistent with the manifest. New
686
+ // flows are never the project default — the caller has to use PATCH
687
+ // /projects/:project/flows/:flow (US-016) to flip defaultFlow.
688
+ api.post('/projects/:project/flows', async (c) => {
689
+ const projectSlug = c.req.param('project');
690
+ const entries = registry.list().filter((e) => e.projectSlug === projectSlug);
691
+ const head = entries[0];
692
+ if (!head) {
693
+ return c.json({ ok: false as const, error: 'project-not-found' as const }, 404);
694
+ }
695
+
696
+ let body: unknown;
697
+ try {
698
+ body = await c.req.json();
699
+ } catch {
700
+ return c.json({ error: 'Body must be valid JSON' }, 400);
701
+ }
702
+ const parsed = CreateFlowBodySchema.safeParse(body);
703
+ if (!parsed.success) {
704
+ return c.json({ error: 'Invalid create flow body', issues: parsed.error.issues }, 400);
705
+ }
706
+ const { id, name, icon } = parsed.data;
707
+
708
+ // Duplicate check: any registered flow in the project or any pre-existing
709
+ // folder under `flows/<id>/` (covers manual edits that never made it to
710
+ // the registry) collides. Manifest entry duplication is structurally
711
+ // impossible if the registry is the source of truth, but the disk check
712
+ // catches drift.
713
+ if (entries.some((e) => e.flowSlug === id)) {
714
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
715
+ }
716
+
717
+ const repoPath = head.repoPath;
718
+ const manifestPath = join(repoPath, 'seeflow.json');
719
+ const manifest = readProjectManifest(repoPath);
720
+ if (!manifest) {
721
+ return c.json(
722
+ {
723
+ ok: false as const,
724
+ error: 'manifest-missing-or-invalid' as const,
725
+ path: manifestPath,
726
+ },
727
+ 500,
728
+ );
729
+ }
730
+ if (manifest.flows.some((f) => f.id === id)) {
731
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
732
+ }
733
+
734
+ const flowDir = join(repoPath, 'flows', id);
735
+ if (existsSync(flowDir)) {
736
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
737
+ }
738
+
739
+ // 1. Create the flow folder + flow.json. Atomic write guarantees no
740
+ // half-written file lands; mkdir is recursive in case `flows/` itself
741
+ // is missing on a partially-scaffolded project.
742
+ try {
743
+ mkdirSync(flowDir, { recursive: true });
744
+ const envelope = { version: 2 as const, name, nodes: [], connectors: [] };
745
+ writeFileAtomic(join(flowDir, 'flow.json'), `${JSON.stringify(envelope, null, 2)}\n`);
746
+ } catch (err) {
747
+ try {
748
+ rmSync(flowDir, { recursive: true, force: true });
749
+ } catch {
750
+ // best-effort cleanup
751
+ }
752
+ return c.json(
753
+ { ok: false as const, error: 'scaffold-failed' as const, detail: String(err) },
754
+ 500,
755
+ );
756
+ }
757
+
758
+ // 2. Append the new entry to the manifest. Roll the folder back if the
759
+ // write fails so the project never has an orphan `flows/<id>/`.
760
+ const manifestEntry: { id: string; name: string; icon?: string } = { id, name };
761
+ if (icon !== undefined) manifestEntry.icon = icon;
762
+ const updatedManifest = {
763
+ ...manifest,
764
+ flows: [...manifest.flows, manifestEntry],
765
+ };
766
+ try {
767
+ writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
768
+ } catch (err) {
769
+ try {
770
+ rmSync(flowDir, { recursive: true, force: true });
771
+ } catch {
772
+ // best-effort cleanup
773
+ }
774
+ return c.json(
775
+ { ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
776
+ 500,
777
+ );
778
+ }
779
+
780
+ // 3. Register the new entry. `flowPath` is project-relative — matches the
781
+ // scanner output for manifest-driven projects.
782
+ const entry = registry.upsert({
783
+ name,
784
+ repoPath,
785
+ flowPath: `flows/${id}/flow.json`,
786
+ projectSlug,
787
+ flowSlug: id,
788
+ isDefault: false,
789
+ icon,
790
+ valid: true,
791
+ });
792
+ watcher?.watch(entry.id);
793
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
794
+
795
+ return c.json(entry, 201);
796
+ });
797
+
798
+ // PATCH /api/projects/:project/flows/:flow — rename a flow id and/or update
799
+ // its name / icon (US-016). Two modes:
800
+ // 1. id change → rename `flows/<oldId>/` to `flows/<newId>/`, rewrite the
801
+ // manifest entry (and `defaultFlow` if it pointed at the renamed flow),
802
+ // then re-bind the registry entry + watcher under the new flowPath. On
803
+ // manifest-write failure, the folder rename is rolled back.
804
+ // 2. name/icon only → manifest-only edit; the filesystem layout and the
805
+ // registry entry id are untouched, only `name` / `icon` are refreshed.
806
+ // The handler is single-flight in the no-collision sense: it serialises
807
+ // through the registry + filesystem so two concurrent id renames against
808
+ // the same project cannot interleave to produce a duplicate folder.
809
+ api.patch('/projects/:project/flows/:flow', async (c) => {
810
+ const projectSlug = c.req.param('project');
811
+ const flowSlug = c.req.param('flow');
812
+ const resolved = resolveProjectFlow(registry, projectSlug, flowSlug);
813
+ if (resolved.kind === 'error') {
814
+ return c.json({ ok: false as const, error: resolved.code }, 404);
815
+ }
816
+ const entry = resolved.entry;
817
+
818
+ let body: unknown;
819
+ try {
820
+ body = await c.req.json();
821
+ } catch {
822
+ return c.json({ error: 'Body must be valid JSON' }, 400);
823
+ }
824
+ const parsed = PatchFlowBodySchema.safeParse(body);
825
+ if (!parsed.success) {
826
+ return c.json({ error: 'Invalid patch flow body', issues: parsed.error.issues }, 400);
827
+ }
828
+ const { id: requestedId, name: requestedName, icon: requestedIcon } = parsed.data;
829
+
830
+ const repoPath = entry.repoPath;
831
+ const manifestPath = join(repoPath, 'seeflow.json');
832
+ const manifest = readProjectManifest(repoPath);
833
+ if (!manifest) {
834
+ return c.json(
835
+ {
836
+ ok: false as const,
837
+ error: 'manifest-missing-or-invalid' as const,
838
+ path: manifestPath,
839
+ },
840
+ 500,
841
+ );
842
+ }
843
+
844
+ const manifestEntryIdx = manifest.flows.findIndex((f) => f.id === entry.flowSlug);
845
+ if (manifestEntryIdx === -1) {
846
+ // Registry and manifest are out of sync — the entry knows itself by
847
+ // flowSlug but the manifest disagrees. Bail rather than rebuild the
848
+ // manifest unilaterally; a future `seeflow reconcile` verb could repair.
849
+ return c.json({ ok: false as const, error: 'manifest-entry-missing' as const }, 500);
850
+ }
851
+ const existingManifestEntry = manifest.flows[manifestEntryIdx];
852
+ if (!existingManifestEntry) {
853
+ return c.json({ ok: false as const, error: 'manifest-entry-missing' as const }, 500);
854
+ }
855
+
856
+ const idChanging = requestedId !== undefined && requestedId !== entry.flowSlug;
857
+
858
+ // Branch 1: id rename. The folder move is the only side-effect we have to
859
+ // undo on manifest-write failure, so it goes BEFORE the manifest write.
860
+ if (idChanging) {
861
+ const newId = requestedId;
862
+ if (newId === undefined) {
863
+ // Narrowing for TS — `idChanging` already implies non-undefined.
864
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
865
+ }
866
+
867
+ // Collision checks: manifest, registry, on-disk folder. Each catches a
868
+ // different drift; cheapest first.
869
+ if (manifest.flows.some((f) => f.id === newId)) {
870
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
871
+ }
872
+ const projectEntries = registry.list().filter((e) => e.projectSlug === projectSlug);
873
+ if (projectEntries.some((e) => e.flowSlug === newId)) {
874
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
875
+ }
876
+ const oldFolder = join(repoPath, 'flows', entry.flowSlug);
877
+ const newFolder = join(repoPath, 'flows', newId);
878
+ if (existsSync(newFolder)) {
879
+ return c.json({ ok: false as const, error: 'duplicate-flow-id' as const }, 409);
880
+ }
881
+
882
+ // 1. Move the folder.
883
+ try {
884
+ renameSync(oldFolder, newFolder);
885
+ } catch (err) {
886
+ return c.json(
887
+ { ok: false as const, error: 'folder-rename-failed' as const, detail: String(err) },
888
+ 500,
889
+ );
890
+ }
891
+
892
+ // 2. Build + atomically write the updated manifest.
893
+ const finalName = requestedName ?? existingManifestEntry.name;
894
+ const finalIcon = requestedIcon !== undefined ? requestedIcon : existingManifestEntry.icon;
895
+ const updatedFlowEntry: { id: string; name: string; icon?: string } = {
896
+ id: newId,
897
+ name: finalName,
898
+ };
899
+ if (finalIcon !== undefined) updatedFlowEntry.icon = finalIcon;
900
+ const updatedManifest: SeeflowManifest = {
901
+ ...manifest,
902
+ defaultFlow: manifest.defaultFlow === entry.flowSlug ? newId : manifest.defaultFlow,
903
+ flows: manifest.flows.map((f, i) => (i === manifestEntryIdx ? updatedFlowEntry : f)),
904
+ };
905
+
906
+ try {
907
+ writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
908
+ } catch (err) {
909
+ try {
910
+ renameSync(newFolder, oldFolder);
911
+ } catch {
912
+ // best-effort rollback
913
+ }
914
+ return c.json(
915
+ { ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
916
+ 500,
917
+ );
918
+ }
919
+
920
+ // 3. Rebind the registry entry under the new flowPath. The shortId
921
+ // changes — registry slugs are addressed via projectSlug/flowSlug, so
922
+ // clients re-resolve via the new URL. Watcher is unwatch-then-watch
923
+ // because the flowPath that the watcher reads is sourced from the
924
+ // new registry entry.
925
+ watcher?.unwatch(entry.id);
926
+ registry.remove(entry.id);
927
+ const newEntry = registry.upsert({
928
+ name: finalName,
929
+ description: entry.description,
930
+ repoPath,
931
+ flowPath: `flows/${newId}/flow.json`,
932
+ projectSlug,
933
+ flowSlug: newId,
934
+ isDefault: entry.isDefault,
935
+ icon: finalIcon,
936
+ valid: entry.valid,
937
+ });
938
+ watcher?.watch(newEntry.id);
939
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
940
+
941
+ return c.json(newEntry);
942
+ }
943
+
944
+ // Branch 2: name / icon only. Filesystem layout untouched.
945
+ const finalName = requestedName ?? existingManifestEntry.name;
946
+ const finalIcon = requestedIcon !== undefined ? requestedIcon : existingManifestEntry.icon;
947
+ const updatedFlowEntry: { id: string; name: string; icon?: string } = {
948
+ id: existingManifestEntry.id,
949
+ name: finalName,
950
+ };
951
+ if (finalIcon !== undefined) updatedFlowEntry.icon = finalIcon;
952
+ const updatedManifest: SeeflowManifest = {
953
+ ...manifest,
954
+ flows: manifest.flows.map((f, i) => (i === manifestEntryIdx ? updatedFlowEntry : f)),
955
+ };
956
+
957
+ try {
958
+ writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
959
+ } catch (err) {
960
+ return c.json(
961
+ { ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
962
+ 500,
963
+ );
964
+ }
965
+
966
+ const updatedEntry = registry.upsert({
967
+ name: finalName,
968
+ description: entry.description,
969
+ repoPath,
970
+ flowPath: entry.flowPath,
971
+ projectSlug,
972
+ flowSlug: entry.flowSlug,
973
+ isDefault: entry.isDefault,
974
+ icon: finalIcon,
975
+ valid: entry.valid,
976
+ });
977
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
978
+
979
+ return c.json(updatedEntry);
980
+ });
981
+
982
+ api.get('/projects/:project/flows/:flow', async (c) => {
983
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
984
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
985
+ const result = await ops.getFlow(resolved.entry.id);
503
986
  switch (result.kind) {
504
987
  case 'ok':
505
988
  return c.json(result.data);
@@ -511,9 +994,11 @@ export function createApi(options: ApiOptions): Hono {
511
994
  });
512
995
 
513
996
  // Flow skeleton without per-node file content (detail.md / view.html).
514
- // Pairs with GET /flows/:id/nodes/:nodeId for full per-node detail.
515
- api.get('/flows/:id/graph', async (c) => {
516
- const result = await ops.getFlowGraph(c.req.param('id'));
997
+ // Pairs with GET /projects/:project/flows/:flow/nodes/:nodeId for full per-node detail.
998
+ api.get('/projects/:project/flows/:flow/graph', async (c) => {
999
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1000
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1001
+ const result = await ops.getFlowGraph(resolved.entry.id);
517
1002
  switch (result.kind) {
518
1003
  case 'ok':
519
1004
  return c.json(result.data);
@@ -528,8 +1013,10 @@ export function createApi(options: ApiOptions): Hono {
528
1013
  }
529
1014
  });
530
1015
 
531
- api.get('/flows/:id/nodes/:nodeId', async (c) => {
532
- const result = await ops.getNode(c.req.param('id'), c.req.param('nodeId'));
1016
+ api.get('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
1017
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1018
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1019
+ const result = await ops.getNode(resolved.entry.id, c.req.param('nodeId'));
533
1020
  switch (result.kind) {
534
1021
  case 'ok':
535
1022
  return c.json(result.data);
@@ -546,11 +1033,13 @@ export function createApi(options: ApiOptions): Hono {
546
1033
  }
547
1034
  });
548
1035
 
549
- // GET /api/projects/:id/files/<path> — stream a project-scoped file from
550
- // <repoPath>/<path>. Path safety is layered: textual rejection (absolute /
551
- // traversal), then realpath check that the resolved file stays inside the
552
- // project root (defends against symlink escapes).
553
- api.get('/projects/:id/files/:path{.+}', async (c) => {
1036
+ // GET /api/projects/:project/files/<path> — stream a project-scoped file
1037
+ // from <repoPath>/<path>. Path safety is layered: textual rejection
1038
+ // (absolute / traversal), then realpath check that the resolved file stays
1039
+ // inside the project root (defends against symlink escapes). The route is
1040
+ // shared across every flow within the project — assets that live at the
1041
+ // project root or under a sibling flow folder are addressable here.
1042
+ api.get('/projects/:project/files/:path{.+}', async (c) => {
554
1043
  const rawPath = c.req.param('path');
555
1044
  let relPath: string;
556
1045
  try {
@@ -559,7 +1048,7 @@ export function createApi(options: ApiOptions): Hono {
559
1048
  return c.json({ error: 'invalid path encoding' }, 400);
560
1049
  }
561
1050
 
562
- const resolved = resolveProjectFile(registry, c.req.param('id'), relPath);
1051
+ const resolved = resolveProjectFile(registry, c.req.param('project'), relPath);
563
1052
  switch (resolved.kind) {
564
1053
  case 'unknownProject':
565
1054
  return c.json({ error: 'unknown project' }, 404);
@@ -582,12 +1071,12 @@ export function createApi(options: ApiOptions): Hono {
582
1071
  });
583
1072
  });
584
1073
 
585
- // POST /api/projects/:id/files/open — shell out to `$EDITOR <abs>` so the
586
- // user can edit a project-scoped file (type:'html' block, image asset) in
587
- // their IDE. The endpoint always returns the resolved absolute path in
1074
+ // POST /api/projects/:project/files/open — shell out to `$EDITOR <abs>` so
1075
+ // the user can edit a project-scoped file (type:'html' block, image asset)
1076
+ // in their IDE. The endpoint always returns the resolved absolute path in
588
1077
  // the response body so the frontend can copy-to-clipboard when $EDITOR
589
1078
  // isn't set or the spawn fails. Path safety mirrors the GET route.
590
- api.post('/projects/:id/files/open', async (c) => {
1079
+ api.post('/projects/:project/files/open', async (c) => {
591
1080
  let body: unknown;
592
1081
  try {
593
1082
  body = await c.req.json();
@@ -599,7 +1088,7 @@ export function createApi(options: ApiOptions): Hono {
599
1088
  return c.json({ error: 'Invalid open body', issues: parsed.error.issues }, 400);
600
1089
  }
601
1090
 
602
- const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
1091
+ const resolved = resolveProjectFile(registry, c.req.param('project'), parsed.data.path);
603
1092
  switch (resolved.kind) {
604
1093
  case 'unknownProject':
605
1094
  return c.json({ error: 'unknown project' }, 404);
@@ -621,12 +1110,12 @@ export function createApi(options: ApiOptions): Hono {
621
1110
  return c.json({ ok: true, absPath: resolved.absPath });
622
1111
  });
623
1112
 
624
- // POST /api/projects/:id/files/reveal — open the OS file manager with the
625
- // target file selected. Platform commands: `open -R <abs>` (macOS),
1113
+ // POST /api/projects/:project/files/reveal — open the OS file manager with
1114
+ // the target file selected. Platform commands: `open -R <abs>` (macOS),
626
1115
  // `explorer /select,<abs>` (Windows), `xdg-open <dir>` (Linux — selects the
627
1116
  // containing directory; xdg has no portable "select-this-file" verb). Same
628
1117
  // fallback shape as /open: response always includes `absPath` for clipboard.
629
- api.post('/projects/:id/files/reveal', async (c) => {
1118
+ api.post('/projects/:project/files/reveal', async (c) => {
630
1119
  let body: unknown;
631
1120
  try {
632
1121
  body = await c.req.json();
@@ -638,7 +1127,7 @@ export function createApi(options: ApiOptions): Hono {
638
1127
  return c.json({ error: 'Invalid reveal body', issues: parsed.error.issues }, 400);
639
1128
  }
640
1129
 
641
- const resolved = resolveProjectFile(registry, c.req.param('id'), parsed.data.path);
1130
+ const resolved = resolveProjectFile(registry, c.req.param('project'), parsed.data.path);
642
1131
  switch (resolved.kind) {
643
1132
  case 'unknownProject':
644
1133
  return c.json({ error: 'unknown project' }, 404);
@@ -672,17 +1161,21 @@ export function createApi(options: ApiOptions): Hono {
672
1161
  return c.json({ ok: true, absPath: resolved.absPath });
673
1162
  });
674
1163
 
675
- // POST /api/projects/:id/nodes/:nodeId/files/upload — accept a multipart
676
- // image upload and persist it under `<project>/nodes/<nodeId>/`. Multipart
677
- // shape: `file` (Blob) and optional `filename` (the original OS name).
678
- // Allowlist + 5 MB cap guard against arbitrary uploads; the destination
679
- // folder is scoped to the node, so delete_node's removeNodeDir cascade
680
- // cleans up the asset along with the node row.
681
- api.post('/projects/:id/nodes/:nodeId/files/upload', async (c) => {
682
- const projectId = c.req.param('id');
1164
+ // POST /api/projects/:project/flows/:flow/nodes/:nodeId/files/upload —
1165
+ // accept a multipart image upload and persist it under
1166
+ // `<repoPath>/<dirname(entry.flowPath)>/nodes/<nodeId>/`. For manifest-
1167
+ // driven projects this resolves to `flows/<flow>/nodes/<nodeId>/`; for
1168
+ // legacy single-flow registrations (flow.json at the project root) it
1169
+ // collapses to `nodes/<nodeId>/`. Multipart shape: `file` (Blob) and
1170
+ // optional `filename` (the original OS name). Allowlist + 5 MB cap guard
1171
+ // against arbitrary uploads; the destination folder is scoped to the node,
1172
+ // so delete_node's removeNodeDir cascade cleans up the asset along with
1173
+ // the node row.
1174
+ api.post('/projects/:project/flows/:flow/nodes/:nodeId/files/upload', async (c) => {
1175
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1176
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1177
+ const entry = resolved.entry;
683
1178
  const nodeId = c.req.param('nodeId');
684
- const entry = registry.getById(projectId);
685
- if (!entry) return c.json({ error: 'unknown project' }, 404);
686
1179
 
687
1180
  // node id shape: `node-<10 base62 chars>` (matches shortId() output).
688
1181
  if (!/^node-[A-Za-z0-9]{10}$/.test(nodeId)) {
@@ -712,7 +1205,8 @@ export function createApi(options: ApiOptions): Hono {
712
1205
  return c.json({ error: 'invalid filename or extension' }, 400);
713
1206
  }
714
1207
 
715
- const nodeDir = join(entry.repoPath, 'nodes', nodeId);
1208
+ const flowDir = dirname(entry.flowPath);
1209
+ const nodeDir = join(entry.repoPath, flowDir, 'nodes', nodeId);
716
1210
  try {
717
1211
  mkdirSync(nodeDir, { recursive: true });
718
1212
  } catch (err) {
@@ -738,25 +1232,149 @@ export function createApi(options: ApiOptions): Hono {
738
1232
  return c.json({ path: `nodes/${nodeId}/${finalName}` });
739
1233
  });
740
1234
 
741
- api.delete('/flows/:id', (c) => {
742
- const result = ops.deleteFlow(c.req.param('id'));
743
- switch (result.kind) {
744
- case 'ok':
745
- return c.json({ ok: true });
746
- case 'notFound':
747
- return c.json({ ok: false, error: 'not found' }, 404);
1235
+ // DELETE /api/projects/:project/flows/:flow — manifest-aware delete (US-017).
1236
+ // Guards:
1237
+ // - last-flow: refuse when the target is the only flow in the project.
1238
+ // - default-flow-no-replacement: refuse when target is manifest.defaultFlow
1239
+ // and no `?newDefault=<other-flow-id>` query arg is supplied.
1240
+ // - invalid-new-default: the supplied newDefault must exist in the
1241
+ // manifest and must not be the flow being deleted.
1242
+ // On success: rename the flow folder to a sibling `.deleted-*` snapshot
1243
+ // (atomic on POSIX), write the updated manifest atomically, then rm the
1244
+ // snapshot and drop the registry entry. On manifest-write failure the
1245
+ // snapshot is renamed back so the externally-observable state is preserved.
1246
+ api.delete('/projects/:project/flows/:flow', (c) => {
1247
+ const projectSlug = c.req.param('project');
1248
+ const flowSlug = c.req.param('flow');
1249
+ const newDefault = c.req.query('newDefault');
1250
+
1251
+ const resolved = resolveProjectFlow(registry, projectSlug, flowSlug);
1252
+ if (resolved.kind === 'error') {
1253
+ return c.json({ ok: false as const, error: resolved.code }, 404);
1254
+ }
1255
+ const entry = resolved.entry;
1256
+
1257
+ const projectEntries = registry.list().filter((e) => e.projectSlug === projectSlug);
1258
+ if (projectEntries.length <= 1) {
1259
+ return c.json({ ok: false as const, error: 'last-flow' as const }, 409);
1260
+ }
1261
+
1262
+ const repoPath = entry.repoPath;
1263
+ const manifestPath = join(repoPath, 'seeflow.json');
1264
+ const manifest = readProjectManifest(repoPath);
1265
+ if (!manifest) {
1266
+ return c.json(
1267
+ {
1268
+ ok: false as const,
1269
+ error: 'manifest-missing-or-invalid' as const,
1270
+ path: manifestPath,
1271
+ },
1272
+ 500,
1273
+ );
1274
+ }
1275
+
1276
+ const targetIsDefault = manifest.defaultFlow === entry.flowSlug;
1277
+ if (targetIsDefault && (newDefault === undefined || newDefault.length === 0)) {
1278
+ return c.json({ ok: false as const, error: 'default-flow-no-replacement' as const }, 409);
1279
+ }
1280
+ if (targetIsDefault && newDefault !== undefined) {
1281
+ if (newDefault === entry.flowSlug) {
1282
+ return c.json({ ok: false as const, error: 'invalid-new-default' as const }, 400);
1283
+ }
1284
+ if (!manifest.flows.some((f) => f.id === newDefault)) {
1285
+ return c.json({ ok: false as const, error: 'invalid-new-default' as const }, 400);
1286
+ }
1287
+ }
1288
+
1289
+ const flowDir = join(repoPath, 'flows', entry.flowSlug);
1290
+ const tmpHolder = join(repoPath, 'flows', `.deleted-${entry.flowSlug}-${Date.now()}`);
1291
+
1292
+ // Snapshot the folder by renaming it to a sibling tmp location. Same
1293
+ // filesystem → POSIX guarantees the rename is atomic. If the folder is
1294
+ // missing on disk (manual `rm -rf` between registration and delete), skip
1295
+ // the snapshot — the manifest+registry mutation still proceeds.
1296
+ let movedToTmp = false;
1297
+ if (existsSync(flowDir)) {
1298
+ try {
1299
+ renameSync(flowDir, tmpHolder);
1300
+ movedToTmp = true;
1301
+ } catch (err) {
1302
+ return c.json(
1303
+ { ok: false as const, error: 'folder-rename-failed' as const, detail: String(err) },
1304
+ 500,
1305
+ );
1306
+ }
748
1307
  }
1308
+
1309
+ const updatedManifest: SeeflowManifest = {
1310
+ ...manifest,
1311
+ defaultFlow: targetIsDefault ? (newDefault as string) : manifest.defaultFlow,
1312
+ flows: manifest.flows.filter((f) => f.id !== entry.flowSlug),
1313
+ };
1314
+
1315
+ try {
1316
+ writeFileAtomic(manifestPath, `${JSON.stringify(updatedManifest, null, 2)}\n`);
1317
+ } catch (err) {
1318
+ if (movedToTmp) {
1319
+ try {
1320
+ renameSync(tmpHolder, flowDir);
1321
+ } catch {
1322
+ // best-effort restore — caller surfaces the original write error
1323
+ }
1324
+ }
1325
+ return c.json(
1326
+ { ok: false as const, error: 'manifest-write-failed' as const, detail: String(err) },
1327
+ 500,
1328
+ );
1329
+ }
1330
+
1331
+ // Manifest committed — drop the snapshot.
1332
+ if (movedToTmp) {
1333
+ try {
1334
+ rmSync(tmpHolder, { recursive: true, force: true });
1335
+ } catch {
1336
+ // best-effort cleanup — manifest is the source of truth now
1337
+ }
1338
+ }
1339
+
1340
+ // Promote the new default in the registry (the upsert finds the existing
1341
+ // entry by repoPath + flowPath and updates in place, preserving its id).
1342
+ if (targetIsDefault && newDefault !== undefined) {
1343
+ const promoted = projectEntries.find((e) => e.flowSlug === newDefault);
1344
+ if (promoted) {
1345
+ registry.upsert({
1346
+ name: promoted.name,
1347
+ description: promoted.description,
1348
+ repoPath: promoted.repoPath,
1349
+ flowPath: promoted.flowPath,
1350
+ projectSlug: promoted.projectSlug,
1351
+ flowSlug: promoted.flowSlug,
1352
+ isDefault: true,
1353
+ icon: promoted.icon,
1354
+ valid: promoted.valid,
1355
+ });
1356
+ }
1357
+ }
1358
+
1359
+ watcher?.unwatch(entry.id);
1360
+ registry.remove(entry.id);
1361
+ events?.broadcast({ type: 'registry:reload', flowId: '__registry__', payload: {} });
1362
+
1363
+ return c.json({ ok: true });
749
1364
  });
750
1365
 
751
- // POST /api/flows/:id/layout — registered-flow ELK layout. Reads flow.json
752
- // from disk via the registry entry, computes layout, writes style.json
753
- // atomically next to flow.json, and broadcasts flow:reload so any open
754
- // canvas refreshes. Body is empty or `{ options? }`. Response on success is
755
- // just `{ ok: true }` — the layout is already on disk. On schema failure
756
- // returns `{ ok: false, issues }` mirroring /api/validate; on missing flow
757
- // file / unknown id / bad JSON / write failure returns HTTP 4xx/5xx.
758
- api.post('/flows/:id/layout', async (c) => {
759
- const id = c.req.param('id');
1366
+ // POST /api/projects/:project/flows/:flow/layout — registered-flow ELK
1367
+ // layout. Reads flow.json from disk via the registry entry, computes
1368
+ // layout, writes style.json atomically next to flow.json, and broadcasts
1369
+ // flow:reload so any open canvas refreshes. Body is empty or `{ options? }`.
1370
+ // Response on success is just `{ ok: true }` — the layout is already on
1371
+ // disk. On schema failure returns `{ ok: false, issues }` mirroring
1372
+ // /api/validate; on missing flow file / bad JSON / write failure returns
1373
+ // HTTP 4xx/5xx.
1374
+ api.post('/projects/:project/flows/:flow/layout', async (c) => {
1375
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1376
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1377
+ const id = resolved.entry.id;
760
1378
 
761
1379
  // Empty body is valid — the skill always uses defaults. Only parse if the
762
1380
  // caller actually sent something.
@@ -794,11 +1412,12 @@ export function createApi(options: ApiOptions): Hono {
794
1412
  }
795
1413
  });
796
1414
 
797
- api.post('/flows/:id/play/:nodeId', async (c) => {
798
- const id = c.req.param('id');
1415
+ api.post('/projects/:project/flows/:flow/play/:nodeId', async (c) => {
1416
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1417
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1418
+ const entry = resolved.entry;
1419
+ const id = entry.id;
799
1420
  const nodeId = c.req.param('nodeId');
800
- const entry = registry.getById(id);
801
- if (!entry) return c.json({ error: 'unknown demo' }, 404);
802
1421
  if (!events) return c.json({ error: 'events not enabled' }, 500);
803
1422
 
804
1423
  // Always re-read from disk so the user's most recent edit (validated or
@@ -852,20 +1471,22 @@ export function createApi(options: ApiOptions): Hono {
852
1471
  return c.json(result);
853
1472
  });
854
1473
 
855
- // POST /api/flows/:id/nodes/:nodeId/actions/:name — dispatch a component
856
- // node's named action over HTTP. Only `script`-kind actions cross this seam;
857
- // `set`-kind actions mutate canvas state locally and never round-trip
858
- // through the API (the runner rejects them with statusHint 400).
859
- // Payload is the JSON request body (defaults to {} on parse failure) and is
860
- // piped to the script's stdin by `runComponentAction`. Response is the
861
- // script's parsed JSON stdout on success.
862
- api.post('/flows/:id/nodes/:nodeId/actions/:name', async (c) => {
863
- const id = c.req.param('id');
1474
+ // POST /api/projects/:project/flows/:flow/nodes/:nodeId/actions/:name —
1475
+ // dispatch a component node's named action over HTTP. Only `script`-kind
1476
+ // actions cross this seam; `set`-kind actions mutate canvas state locally
1477
+ // and never round-trip through the API (the runner rejects them with
1478
+ // statusHint 400). Payload is the JSON request body (defaults to {} on
1479
+ // parse failure) and is piped to the script's stdin by
1480
+ // `runComponentAction`. Response is the script's parsed JSON stdout on
1481
+ // success.
1482
+ api.post('/projects/:project/flows/:flow/nodes/:nodeId/actions/:name', async (c) => {
1483
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1484
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1485
+ const entry = resolved.entry;
1486
+ const id = entry.id;
864
1487
  const nodeId = c.req.param('nodeId');
865
1488
  const actionName = c.req.param('name');
866
1489
 
867
- const entry = registry.getById(id);
868
- if (!entry) return c.json({ error: 'unknown demo' }, 404);
869
1490
  if (!events) return c.json({ error: 'events not enabled' }, 500);
870
1491
 
871
1492
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
@@ -888,11 +1509,15 @@ export function createApi(options: ApiOptions): Hono {
888
1509
  if (!action) return c.json({ error: `Unknown action: ${actionName}` }, 404);
889
1510
 
890
1511
  const payload = await c.req.json().catch(() => ({}));
1512
+ // US-031: per-node sidecar scripts now anchor at
1513
+ // `<repoPath>/<dirname(flowPath)>/nodes/<id>/` post-multi-flow migration —
1514
+ // the runner still resolves scripts as `<cwd>/nodes/<nodeId>/<scriptPath>`,
1515
+ // so feed the per-flow folder as `cwd`.
891
1516
  const result = await runComponentAction({
892
1517
  events,
893
1518
  flowId: id,
894
1519
  nodeId,
895
- cwd: entry.repoPath,
1520
+ cwd: join(entry.repoPath, dirname(entry.flowPath)),
896
1521
  actionName,
897
1522
  action,
898
1523
  payload,
@@ -904,7 +1529,8 @@ export function createApi(options: ApiOptions): Hono {
904
1529
  return c.json(result.body);
905
1530
  });
906
1531
 
907
- // POST /api/flows/:id/reset — the "Restart demo" workflow (US-008). Order:
1532
+ // POST /api/projects/:project/flows/:flow/reset — the "Restart demo"
1533
+ // workflow (US-008). Order:
908
1534
  // 1. Stop every live play-script + every long-running status-script for
909
1535
  // this demo in parallel — both must complete before any reset script
910
1536
  // spawns so the script sees no stragglers.
@@ -914,10 +1540,11 @@ export function createApi(options: ApiOptions): Hono {
914
1540
  // 4. Fire-and-forget `statusRunner.restart` so the next status batch is
915
1541
  // spawning by the time the response lands. Individual spawn failures
916
1542
  // surface via console.warn but never fail the /reset call.
917
- api.post('/flows/:id/reset', async (c) => {
918
- const id = c.req.param('id');
919
- const entry = registry.getById(id);
920
- if (!entry) return c.json({ error: 'unknown demo' }, 404);
1543
+ api.post('/projects/:project/flows/:flow/reset', async (c) => {
1544
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1545
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1546
+ const entry = resolved.entry;
1547
+ const id = entry.id;
921
1548
  if (!events) return c.json({ error: 'events not enabled' }, 500);
922
1549
 
923
1550
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
@@ -981,8 +1608,10 @@ export function createApi(options: ApiOptions): Hono {
981
1608
  // PATCH a single node's position back into the on-disk flow.json. Atomic
982
1609
  // write via tempfile + rename keeps editor diffs clean and avoids
983
1610
  // corruption mid-write.
984
- api.patch('/flows/:id/nodes/:nodeId/position', async (c) => {
985
- const id = c.req.param('id');
1611
+ api.patch('/projects/:project/flows/:flow/nodes/:nodeId/position', async (c) => {
1612
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1613
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1614
+ const id = resolved.entry.id;
986
1615
  const nodeId = c.req.param('nodeId');
987
1616
 
988
1617
  let body: unknown;
@@ -1022,8 +1651,10 @@ export function createApi(options: ApiOptions): Hono {
1022
1651
  // toBack (remove + push/unshift), and toIndex (pin to an absolute index)
1023
1652
  // which the undo path uses to faithfully revert forward/backward gestures
1024
1653
  // even if the array changed between the original op and the undo.
1025
- api.patch('/flows/:id/nodes/:nodeId/order', async (c) => {
1026
- const id = c.req.param('id');
1654
+ api.patch('/projects/:project/flows/:flow/nodes/:nodeId/order', async (c) => {
1655
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1656
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1657
+ const id = resolved.entry.id;
1027
1658
  const nodeId = c.req.param('nodeId');
1028
1659
 
1029
1660
  let body: unknown;
@@ -1063,8 +1694,10 @@ export function createApi(options: ApiOptions): Hono {
1063
1694
  // doesn't yet recognize survive round-trips) and the WHOLE resulting demo
1064
1695
  // is re-validated through ResolvedFlowSchema before commit, preventing partial
1065
1696
  // writes from breaking invariants like the connector→node superRefine.
1066
- api.patch('/flows/:id/nodes/:nodeId', async (c) => {
1067
- const id = c.req.param('id');
1697
+ api.patch('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
1698
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1699
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1700
+ const id = resolved.entry.id;
1068
1701
  const nodeId = c.req.param('nodeId');
1069
1702
 
1070
1703
  let body: unknown;
@@ -1100,8 +1733,10 @@ export function createApi(options: ApiOptions): Hono {
1100
1733
  // POST a new node into the demo. Body is the node payload (id auto-generated
1101
1734
  // server-side if absent). Atomicity + final-ResolvedFlowSchema validation match the
1102
1735
  // PATCH path above, so a malformed node never produces a half-written file.
1103
- api.post('/flows/:id/nodes', async (c) => {
1104
- const id = c.req.param('id');
1736
+ api.post('/projects/:project/flows/:flow/nodes', async (c) => {
1737
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1738
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1739
+ const id = resolved.entry.id;
1105
1740
 
1106
1741
  let body: unknown;
1107
1742
  try {
@@ -1137,8 +1772,10 @@ export function createApi(options: ApiOptions): Hono {
1137
1772
  // nodes added in the same call; the parse sees the merged graph as a whole.
1138
1773
  // Intended for skill/LLM seeding where multiple singular calls would burn
1139
1774
  // tokens and round-trip latency.
1140
- api.post('/flows/:id/bulk', async (c) => {
1141
- const id = c.req.param('id');
1775
+ api.post('/projects/:project/flows/:flow/bulk', async (c) => {
1776
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1777
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1778
+ const id = resolved.entry.id;
1142
1779
 
1143
1780
  let body: unknown;
1144
1781
  try {
@@ -1186,8 +1823,10 @@ export function createApi(options: ApiOptions): Hono {
1186
1823
  // is still run after the mutation — connector cascade closure means it
1187
1824
  // should always pass, but the check makes the failure mode honest if the
1188
1825
  // file had a pre-existing schema violation we'd otherwise paper over.
1189
- api.delete('/flows/:id/nodes/:nodeId', async (c) => {
1190
- const id = c.req.param('id');
1826
+ api.delete('/projects/:project/flows/:flow/nodes/:nodeId', async (c) => {
1827
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1828
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1829
+ const id = resolved.entry.id;
1191
1830
  const nodeId = c.req.param('nodeId');
1192
1831
 
1193
1832
  const result = await ops.deleteNode(id, nodeId);
@@ -1216,8 +1855,10 @@ export function createApi(options: ApiOptions): Hono {
1216
1855
  // union catches missing-required-fields (e.g. kind='event' without
1217
1856
  // eventName) and the superRefine still gates source/target referential
1218
1857
  // integrity.
1219
- api.patch('/flows/:id/connectors/:connId', async (c) => {
1220
- const id = c.req.param('id');
1858
+ api.patch('/projects/:project/flows/:flow/connectors/:connId', async (c) => {
1859
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1860
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1861
+ const id = resolved.entry.id;
1221
1862
  const connId = c.req.param('connId');
1222
1863
 
1223
1864
  let body: unknown;
@@ -1254,8 +1895,10 @@ export function createApi(options: ApiOptions): Hono {
1254
1895
  // server-side if absent and `kind` defaults to 'default' (the no-semantics
1255
1896
  // user-drawn variant). Source/target referential integrity is enforced by
1256
1897
  // ResolvedFlowSchema's superRefine on the post-mutation parse.
1257
- api.post('/flows/:id/connectors', async (c) => {
1258
- const id = c.req.param('id');
1898
+ api.post('/projects/:project/flows/:flow/connectors', async (c) => {
1899
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1900
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1901
+ const id = resolved.entry.id;
1259
1902
 
1260
1903
  let body: unknown;
1261
1904
  try {
@@ -1286,8 +1929,10 @@ export function createApi(options: ApiOptions): Hono {
1286
1929
 
1287
1930
  // DELETE a connector. Just removes the entry from demo.connectors — node
1288
1931
  // deletion is what cascades, not connector deletion.
1289
- api.delete('/flows/:id/connectors/:connId', async (c) => {
1290
- const id = c.req.param('id');
1932
+ api.delete('/projects/:project/flows/:flow/connectors/:connId', async (c) => {
1933
+ const resolved = resolveProjectFlow(registry, c.req.param('project'), c.req.param('flow'));
1934
+ if (resolved.kind === 'error') return c.json({ ok: false, error: resolved.code }, 404);
1935
+ const id = resolved.entry.id;
1291
1936
  const connId = c.req.param('connId');
1292
1937
 
1293
1938
  const result = await ops.deleteConnector(id, connId);