@tuongaz/seeflow 0.1.77 → 0.1.80

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-id0XTZQC.js} +1 -1
  3. package/dist/web/assets/{blockDiagram-GPEHLZMM-MAYYm7FM.js → blockDiagram-GPEHLZMM-Cjvfg0ZP.js} +1 -1
  4. package/dist/web/assets/{c4Diagram-AAUBKEIU-7P7yfHg1.js → c4Diagram-AAUBKEIU-Dyq-0e8Q.js} +1 -1
  5. package/dist/web/assets/channel-Ajb6KiL3.js +1 -0
  6. package/dist/web/assets/{chart-C68vupBE.js → chart-DuTGW-Dj.js} +1 -1
  7. package/dist/web/assets/{chunk-2J33WTMH-Bb4cSusI.js → chunk-2J33WTMH-DsD65OzD.js} +1 -1
  8. package/dist/web/assets/{chunk-4BX2VUAB-DXYpcpTh.js → chunk-4BX2VUAB-BpytKE8P.js} +1 -1
  9. package/dist/web/assets/{chunk-55IACEB6-BxuYKDnf.js → chunk-55IACEB6-DIILAUq9.js} +1 -1
  10. package/dist/web/assets/{chunk-727SXJPM-DbWlxAr2.js → chunk-727SXJPM-C4ih-gTo.js} +1 -1
  11. package/dist/web/assets/{chunk-AQP2D5EJ-DT8S1q80.js → chunk-AQP2D5EJ-BsYoWdVM.js} +1 -1
  12. package/dist/web/assets/{chunk-FMBD7UC4-Dc0wDuZz.js → chunk-FMBD7UC4-Db6L0z4p.js} +1 -1
  13. package/dist/web/assets/{chunk-ND2GUHAM-CqLLK6H0.js → chunk-ND2GUHAM-BNLqZYMx.js} +1 -1
  14. package/dist/web/assets/{chunk-QZHKN3VN-CxF7nkDI.js → chunk-QZHKN3VN-DL5PK45j.js} +1 -1
  15. package/dist/web/assets/classDiagram-4FO5ZUOK-Cgw6ezRo.js +1 -0
  16. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Cgw6ezRo.js +1 -0
  17. package/dist/web/assets/{code-block-DR9fiK_U.js → code-block-C1SJv-Al.js} +1 -1
  18. package/dist/web/assets/{cose-bilkent-S5V4N54A-BflFbtY2.js → cose-bilkent-S5V4N54A-ChX5nR0f.js} +1 -1
  19. package/dist/web/assets/{dagre-BM42HDAG-BJ5UdyYS.js → dagre-BM42HDAG-BXeL3fEN.js} +1 -1
  20. package/dist/web/assets/{diagram-2AECGRRQ-D0M8fCf7.js → diagram-2AECGRRQ-B6WtmEP-.js} +1 -1
  21. package/dist/web/assets/{diagram-5GNKFQAL-D67gAMS4.js → diagram-5GNKFQAL-SXs7ALwM.js} +1 -1
  22. package/dist/web/assets/{diagram-KO2AKTUF-XX62HBG-.js → diagram-KO2AKTUF-D5zylPYo.js} +1 -1
  23. package/dist/web/assets/{diagram-LMA3HP47-DCFq3Oac.js → diagram-LMA3HP47-CByIUlQF.js} +1 -1
  24. package/dist/web/assets/{diagram-OG6HWLK6-Be392NCN.js → diagram-OG6HWLK6-BH1MfUqV.js} +1 -1
  25. package/dist/web/assets/{erDiagram-TEJ5UH35-DP4eP0as.js → erDiagram-TEJ5UH35-BOOnRFBh.js} +1 -1
  26. package/dist/web/assets/{flowDiagram-I6XJVG4X-Ch1GVJ9R.js → flowDiagram-I6XJVG4X-BynWDHJP.js} +1 -1
  27. package/dist/web/assets/{ganttDiagram-6RSMTGT7-DtvkTizu.js → ganttDiagram-6RSMTGT7-Cgq_djyN.js} +1 -1
  28. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-YGcuBgb9.js → gitGraphDiagram-PVQCEYII-ciGSgmfT.js} +1 -1
  29. package/dist/web/assets/index-DiakpHyc.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-C7TtaIfa.js} +1 -1
  32. package/dist/web/assets/{infoDiagram-5YYISTIA-wce0BORz.js → infoDiagram-5YYISTIA-DqMb3_c-.js} +1 -1
  33. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-u2MvPgdW.js → ishikawaDiagram-YF4QCWOH-CAO6KqQU.js} +1 -1
  34. package/dist/web/assets/{journeyDiagram-JHISSGLW-BsOyrTiA.js → journeyDiagram-JHISSGLW-Di8MsLTo.js} +1 -1
  35. package/dist/web/assets/{jspdf.es.min-ptMERvnN.js → jspdf.es.min-Cq4dY-lT.js} +3 -3
  36. package/dist/web/assets/{kanban-definition-UN3LZRKU-BaraYV9q.js → kanban-definition-UN3LZRKU-ClOmVNcX.js} +1 -1
  37. package/dist/web/assets/{linear-BVqXcDUJ.js → linear-B3OKBKaT.js} +1 -1
  38. package/dist/web/assets/{markdown-DqP0Cywq.js → markdown-Dg8NEx1K.js} +1 -1
  39. package/dist/web/assets/{mermaid.core-CakR_vo1.js → mermaid.core-Bw-m7bH-.js} +4 -4
  40. package/dist/web/assets/{mindmap-definition-RKZ34NQL-CO5AsZw3.js → mindmap-definition-RKZ34NQL-CUBA1zfc.js} +1 -1
  41. package/dist/web/assets/{pieDiagram-4H26LBE5-CiDJY-kx.js → pieDiagram-4H26LBE5-Dux5HvSU.js} +1 -1
  42. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BS6oN3s_.js → quadrantDiagram-W4KKPZXB-DU3gQGo3.js} +1 -1
  43. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNbUR_FF.js → requirementDiagram-4Y6WPE33-CD3A_U9j.js} +1 -1
  44. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-0Esj5uzm.js → sankeyDiagram-5OEKKPKP-Cd4mc26P.js} +1 -1
  45. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-DR3U38Zi.js → sequenceDiagram-3UESZ5HK-Da0iOMgq.js} +1 -1
  46. package/dist/web/assets/{stateDiagram-AJRCARHV-C50RQjWe.js → stateDiagram-AJRCARHV-P94LaOD2.js} +1 -1
  47. package/dist/web/assets/stateDiagram-v2-BHNVJYJU--JLHF28o.js +1 -0
  48. package/dist/web/assets/{time-C_2J9tFX.js → time-0JEErjjJ.js} +1 -1
  49. package/dist/web/assets/{timeline-definition-PNZ67QCA-BQXyo2r_.js → timeline-definition-PNZ67QCA-BqAYomix.js} +1 -1
  50. package/dist/web/assets/{vennDiagram-CIIHVFJN-DZJ8M3EA.js → vennDiagram-CIIHVFJN-BWuPhfIM.js} +1 -1
  51. package/dist/web/assets/{wardley-L42UT6IY-B96HtW3i.js → wardley-L42UT6IY-iiGkgUQj.js} +1 -1
  52. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BHkQ79WC.js → wardleyDiagram-YWT4CUSO-CtqzFQXL.js} +1 -1
  53. package/dist/web/assets/{xychartDiagram-2RQKCTM6-B_f8koGI.js → xychartDiagram-2RQKCTM6-BGrOXndI.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/operations.ts CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  removeNodeDir,
23
23
  writeNodeFile,
24
24
  } from './node-files.ts';
25
- import type { Registry } from './registry.ts';
25
+ import { scanProject } from './project-scanner.ts';
26
+ import { type Registry, slugify } from './registry.ts';
26
27
  import {
27
28
  ColorTokenSchema,
28
29
  ComponentSpecSchema,
@@ -33,6 +34,7 @@ import {
33
34
  PlayActionSchema,
34
35
  type ResolvedFlow,
35
36
  ResolvedFlowSchema,
37
+ type SeeflowManifest,
36
38
  SourceHandleIdSchema,
37
39
  StateSourceSchema,
38
40
  StatusActionSchema,
@@ -42,11 +44,6 @@ import {
42
44
  import { shortId } from './short-id.ts';
43
45
  import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
44
46
 
45
- // Both projects:create and flows:register write/read flow.json at the project
46
- // root. The studio never assumes a `.seeflow/` subdirectory — whatever path
47
- // the caller supplies is treated as the seeflow project root.
48
- const PROJECT_FLOW_RELATIVE_PATH = 'flow.json';
49
-
50
47
  export const RegisterBodySchema = z.object({
51
48
  name: z.string().min(1).optional(),
52
49
  repoPath: z.string().min(1),
@@ -1132,11 +1129,15 @@ export async function registerFlowImpl(
1132
1129
  if (!merged.flow) return { kind: 'badJson', detail: merged.error ?? 'unknown error' };
1133
1130
 
1134
1131
  const lastModified = statSync(fullPath).mtimeMs;
1132
+ const flowName = body.name ?? merged.flow.name;
1135
1133
  const entry = registry.upsert({
1136
- name: body.name ?? merged.flow.name,
1134
+ name: flowName,
1137
1135
  description: merged.flow.description,
1138
1136
  repoPath,
1139
1137
  flowPath,
1138
+ projectSlug: slugify(flowName),
1139
+ flowSlug: 'main',
1140
+ isDefault: true,
1140
1141
  valid: true,
1141
1142
  lastModified,
1142
1143
  });
@@ -1168,24 +1169,36 @@ export async function createProjectImpl(
1168
1169
  const { registry, watcher } = deps;
1169
1170
  const { path: folderPath, name, description } = body;
1170
1171
 
1171
- const demoFullPath = join(folderPath, PROJECT_FLOW_RELATIVE_PATH);
1172
+ // Manifest-driven layout (US-018): a project is the seeflow.json manifest
1173
+ // plus one flow folder under flows/<id>/. The default flow id for a
1174
+ // freshly-scaffolded project is always "main".
1175
+ const manifestPath = join(folderPath, 'seeflow.json');
1176
+ const legacyFlowPath = join(folderPath, 'flow.json');
1177
+ const mainFlowPath = join(folderPath, 'flows', 'main', 'flow.json');
1172
1178
 
1173
- if (existsSync(demoFullPath)) {
1179
+ if (existsSync(manifestPath) || existsSync(legacyFlowPath) || existsSync(mainFlowPath)) {
1174
1180
  return { kind: 'alreadyExists', path: folderPath };
1175
1181
  }
1176
1182
 
1177
- // Flow-only scaffold: empty nodes/connectors, no style.json needed.
1178
- const scaffold: Flow = {
1179
- version: 2,
1183
+ const manifest: SeeflowManifest = {
1184
+ version: 1,
1180
1185
  name,
1181
1186
  ...(description !== undefined ? { description } : {}),
1187
+ defaultFlow: 'main',
1188
+ flows: [{ id: 'main', name: 'Main' }],
1189
+ };
1190
+ const envelope: Flow = {
1191
+ version: 2,
1192
+ name,
1182
1193
  nodes: [],
1183
1194
  connectors: [],
1184
1195
  };
1185
1196
 
1186
1197
  try {
1187
1198
  mkdirSync(folderPath, { recursive: true });
1188
- writeFileSync(demoFullPath, `${JSON.stringify(scaffold, null, 2)}\n`);
1199
+ mkdirSync(join(folderPath, 'flows', 'main'), { recursive: true });
1200
+ writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
1201
+ writeFileAtomic(mainFlowPath, `${JSON.stringify(envelope, null, 2)}\n`);
1189
1202
  const tmpDir = join(folderPath, '.tmp');
1190
1203
  mkdirSync(tmpDir, { recursive: true });
1191
1204
  writeFileSync(join(tmpDir, '.gitignore'), '*\n!.gitignore\n');
@@ -1193,12 +1206,32 @@ export async function createProjectImpl(
1193
1206
  return { kind: 'scaffoldFailed', message: err instanceof Error ? err.message : String(err) };
1194
1207
  }
1195
1208
 
1196
- const lastModified = statSync(demoFullPath).mtimeMs;
1209
+ // Run the scanner so projectSlug is derived consistently with manifest-driven
1210
+ // projects registered via the CLI's `register` verb / the file-watcher.
1211
+ const scan = scanProject(folderPath);
1212
+ if (scan.kind !== 'ok') {
1213
+ return {
1214
+ kind: 'scaffoldFailed',
1215
+ message: `scanner refused freshly-scaffolded project: ${scan.kind}`,
1216
+ };
1217
+ }
1218
+ const mainScanned = scan.flows.find((f) => f.id === 'main');
1219
+ if (!mainScanned) {
1220
+ return {
1221
+ kind: 'scaffoldFailed',
1222
+ message: 'scanner did not return the "main" flow entry',
1223
+ };
1224
+ }
1225
+
1226
+ const lastModified = statSync(mainFlowPath).mtimeMs;
1197
1227
  const entry = registry.upsert({
1198
- name,
1199
- description,
1228
+ name: mainScanned.name,
1229
+ description: scan.manifest.description,
1200
1230
  repoPath: folderPath,
1201
- flowPath: PROJECT_FLOW_RELATIVE_PATH,
1231
+ flowPath: mainScanned.flowPath,
1232
+ projectSlug: scan.projectSlug,
1233
+ flowSlug: mainScanned.id,
1234
+ isDefault: mainScanned.isDefault,
1202
1235
  valid: true,
1203
1236
  lastModified,
1204
1237
  });
@@ -1239,12 +1272,13 @@ export async function addNodeImpl(
1239
1272
  const data: Record<string, unknown> = dataIsRecord
1240
1273
  ? { ...(newNode.data as Record<string, unknown>) }
1241
1274
  : {};
1275
+ const flowDir = dirname(entry.flowPath);
1242
1276
  for (const { field, fileName } of externalizedFieldsForNodeType(newNode.type)) {
1243
1277
  const incoming = data[field];
1244
1278
  const content = typeof incoming === 'string' ? incoming : '';
1245
1279
  data[field] = nodeFileRef(newId, fileName);
1246
1280
  externalized.push({
1247
- absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
1281
+ absPath: nodeFileAbsPath(entry.repoPath, flowDir, newId, fileName),
1248
1282
  content,
1249
1283
  });
1250
1284
  }
@@ -1303,6 +1337,7 @@ export async function addFlowBulkImpl(
1303
1337
  externalized: Array<{ absPath: string; content: string }>;
1304
1338
  }> = [];
1305
1339
  const nodeIdsInBatch = new Set<string>();
1340
+ const flowDir = dirname(entry.flowPath);
1306
1341
  for (const item of body.nodes ?? []) {
1307
1342
  const newNode = { ...item };
1308
1343
  if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
@@ -1327,7 +1362,7 @@ export async function addFlowBulkImpl(
1327
1362
  const content = typeof incoming === 'string' ? incoming : '';
1328
1363
  data[field] = nodeFileRef(newId, fileName);
1329
1364
  externalized.push({
1330
- absPath: nodeFileAbsPath(entry.repoPath, newId, fileName),
1365
+ absPath: nodeFileAbsPath(entry.repoPath, flowDir, newId, fileName),
1331
1366
  content,
1332
1367
  });
1333
1368
  }
@@ -1412,11 +1447,11 @@ export async function addFlowBulkImpl(
1412
1447
  // Non-ok branch: the post-mutation ResolvedFlowSchema parse (or a later
1413
1448
  // writeFailed) ran AFTER the mutator already wrote per-node folders. The
1414
1449
  // collide-with-existing check ran first inside the mutator, so any folder
1415
- // at `nodes/<p.id>/` was created by this call — safe to cascade. The
1416
- // idAlreadyExists branch returns before any writeNodeFile, so the rmdir is
1417
- // a no-op there.
1450
+ // at `<flowDir>/nodes/<p.id>/` was created by this call — safe to cascade.
1451
+ // The idAlreadyExists branch returns before any writeNodeFile, so the rmdir
1452
+ // is a no-op there.
1418
1453
  for (const p of preparedNodes) {
1419
- removeNodeDir(entry.repoPath, p.id);
1454
+ removeNodeDir(entry.repoPath, flowDir, p.id);
1420
1455
  }
1421
1456
  return result;
1422
1457
  }
@@ -1425,8 +1460,8 @@ export async function addFlowBulkImpl(
1425
1460
  // atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
1426
1461
  // schema violation surfaces honestly instead of being silently papered over.
1427
1462
  // After the flow.json write, `removeNodeDir` cascades the node's whole
1428
- // `<project>/nodes/<id>/` folder — covering detail.md, view.html, and any
1429
- // type:'image' upload that lived there.
1463
+ // `<repoPath>/<flowDir>/nodes/<id>/` folder — covering detail.md, view.html,
1464
+ // and any type:'image' upload that lived there.
1430
1465
  export async function deleteNodeImpl(
1431
1466
  deps: OperationsDeps,
1432
1467
  flowId: string,
@@ -1455,7 +1490,7 @@ export async function deleteNodeImpl(
1455
1490
 
1456
1491
  if (result.kind === 'ok') {
1457
1492
  try {
1458
- removeNodeDir(entry.repoPath, nodeId);
1493
+ removeNodeDir(entry.repoPath, dirname(entry.flowPath), nodeId);
1459
1494
  } catch (err) {
1460
1495
  // Best-effort: flow.json is already written and the orphan folder is
1461
1496
  // recoverable manually. ids are random so a future add_node won't collide.
@@ -1517,6 +1552,7 @@ export async function patchNodeImpl(
1517
1552
  const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
1518
1553
  if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1519
1554
 
1555
+ const flowDir = dirname(entry.flowPath);
1520
1556
  return mutateMergedFlowAndBroadcast<
1521
1557
  { kind: 'unknownNode' } | { kind: 'writeFailed'; message: string }
1522
1558
  >(deps, flowId, fullPath, (flow) => {
@@ -1532,7 +1568,7 @@ export async function patchNodeImpl(
1532
1568
  const incoming = (updates as Record<string, unknown>)[field];
1533
1569
  if (incoming === undefined) continue;
1534
1570
  externalizedWrites.push({
1535
- absPath: nodeFileAbsPath(entry.repoPath, nodeId, fileName),
1571
+ absPath: nodeFileAbsPath(entry.repoPath, flowDir, nodeId, fileName),
1536
1572
  ref: nodeFileRef(nodeId, fileName),
1537
1573
  field,
1538
1574
  content: typeof incoming === 'string' ? incoming : '',
@@ -1559,12 +1595,12 @@ export async function patchNodeImpl(
1559
1595
  node.data = data;
1560
1596
  }
1561
1597
  // Component spec sidecar — write the pretty-printed JSON to
1562
- // `<project>/nodes/<id>/spec.json` so the on-disk source of truth stays
1563
- // in sync. mergeNodeUpdates already put data.spec on the merged tree for
1564
- // the post-mutation ResolvedFlowSchema parse; splitFlow strips it from
1565
- // flow.json so we don't double-store the spec.
1598
+ // `<repoPath>/<flowDir>/nodes/<id>/spec.json` so the on-disk source of
1599
+ // truth stays in sync. mergeNodeUpdates already put data.spec on the
1600
+ // merged tree for the post-mutation ResolvedFlowSchema parse; splitFlow
1601
+ // strips it from flow.json so we don't double-store the spec.
1566
1602
  if (node.type === 'component' && updates.spec !== undefined) {
1567
- const specAbs = nodeFileAbsPath(entry.repoPath, nodeId, 'spec.json');
1603
+ const specAbs = nodeFileAbsPath(entry.repoPath, flowDir, nodeId, 'spec.json');
1568
1604
  try {
1569
1605
  writeNodeFile(specAbs, `${JSON.stringify(updates.spec, null, 2)}\n`);
1570
1606
  } catch (err) {
@@ -0,0 +1,105 @@
1
+ // Project scanner: turns a project directory on disk into a set of
2
+ // `ScannedFlow` entries the registry can consume. The disk layout is
3
+ // manifest-driven:
4
+ //
5
+ // <repoPath>/seeflow.json — top-level project manifest
6
+ // <repoPath>/flows/<id>/flow.json — one folder per flow declared in manifest
7
+ //
8
+ // `scanProject(repoPath)` returns a discriminated union: either `{ kind: 'ok' }`
9
+ // with the parsed manifest + per-flow entries, or one of the typed error
10
+ // variants below. The CLI wires this into `registerProject(opts)` in US-004 and
11
+ // turns each error variant into a structured exit.
12
+
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { basename, join } from 'node:path';
15
+ import { type SeeflowManifest, SeeflowManifestSchema } from './schema.ts';
16
+ import { slugify } from './slugify.ts';
17
+
18
+ export interface ScannedFlow {
19
+ /** Manifest flow id (matches /^[a-z0-9][a-z0-9-]*$/). Becomes flowSlug. */
20
+ id: string;
21
+ /** Human-readable name from manifest. */
22
+ name: string;
23
+ /** Optional decorative icon name. */
24
+ icon?: string;
25
+ /** True for the flow whose id matches manifest.defaultFlow. */
26
+ isDefault: boolean;
27
+ /** Relative path of flow.json under the project root. Always
28
+ * `flows/<id>/flow.json` — the scanner does not honour overrides. */
29
+ flowPath: string;
30
+ }
31
+
32
+ export type ScanError =
33
+ | { kind: 'manifest-missing' }
34
+ | { kind: 'manifest-invalid'; message: string }
35
+ | { kind: 'legacy-root-flow' }
36
+ | { kind: 'flow-json-missing'; flowId: string; flowPath: string };
37
+
38
+ export type ScanResult =
39
+ | {
40
+ kind: 'ok';
41
+ projectSlug: string;
42
+ manifest: SeeflowManifest;
43
+ flows: ScannedFlow[];
44
+ }
45
+ | ScanError;
46
+
47
+ const MANIFEST_FILENAME = 'seeflow.json';
48
+ const LEGACY_FLOW_FILENAME = 'flow.json';
49
+
50
+ export function scanProject(repoPath: string): ScanResult {
51
+ const manifestPath = join(repoPath, MANIFEST_FILENAME);
52
+ if (!existsSync(manifestPath)) {
53
+ // Pre-multi-flow projects had `flow.json` at the project root. We refuse
54
+ // to silently treat those as single-flow projects — the migration story
55
+ // (US-005) moves them to `flows/main/flow.json` first.
56
+ if (existsSync(join(repoPath, LEGACY_FLOW_FILENAME))) {
57
+ return { kind: 'legacy-root-flow' };
58
+ }
59
+ return { kind: 'manifest-missing' };
60
+ }
61
+
62
+ let raw: unknown;
63
+ try {
64
+ raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
65
+ } catch (err) {
66
+ return {
67
+ kind: 'manifest-invalid',
68
+ message: `failed to parse ${MANIFEST_FILENAME}: ${(err as Error).message}`,
69
+ };
70
+ }
71
+
72
+ const parsed = SeeflowManifestSchema.safeParse(raw);
73
+ if (!parsed.success) {
74
+ return {
75
+ kind: 'manifest-invalid',
76
+ message: parsed.error.issues
77
+ .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
78
+ .join('; '),
79
+ };
80
+ }
81
+
82
+ const manifest = parsed.data;
83
+ const flows: ScannedFlow[] = [];
84
+ for (const entry of manifest.flows) {
85
+ const flowPath = `flows/${entry.id}/${LEGACY_FLOW_FILENAME}`;
86
+ if (!existsSync(join(repoPath, flowPath))) {
87
+ return { kind: 'flow-json-missing', flowId: entry.id, flowPath };
88
+ }
89
+ flows.push({
90
+ id: entry.id,
91
+ name: entry.name,
92
+ icon: entry.icon,
93
+ isDefault: entry.id === manifest.defaultFlow,
94
+ flowPath,
95
+ });
96
+ }
97
+
98
+ // Prefer the manifest name. If slugify falls back to its `'demo'` sentinel
99
+ // (i.e. manifest.name had no alphanumeric content), fall back to the
100
+ // directory basename — which itself slugifies through to a stable identifier.
101
+ const nameSlug = slugify(manifest.name);
102
+ const projectSlug = nameSlug === 'demo' ? slugify(basename(repoPath)) : nameSlug;
103
+
104
+ return { kind: 'ok', projectSlug, manifest, flows };
105
+ }
package/src/registry.ts CHANGED
@@ -4,14 +4,23 @@ import { dirname, join } from 'node:path';
4
4
  import { writeFileAtomic } from './atomic-write.ts';
5
5
  import { seeflowHome } from './paths.ts';
6
6
  import { shortId } from './short-id.ts';
7
+ import { slugify } from './slugify.ts';
8
+
9
+ export { slugify };
7
10
 
8
11
  export interface FlowEntry {
9
12
  id: string;
13
+ /** Derived as `${projectSlug}/${flowSlug}` — kept on the entry for
14
+ * resolve() / getBySlug() compatibility. */
10
15
  slug: string;
11
16
  name: string;
12
17
  description?: string;
13
18
  repoPath: string;
14
19
  flowPath: string;
20
+ projectSlug: string;
21
+ flowSlug: string;
22
+ isDefault: boolean;
23
+ icon?: string;
15
24
  lastModified: number;
16
25
  valid: boolean;
17
26
  }
@@ -21,6 +30,10 @@ export interface RegisterInput {
21
30
  description?: string;
22
31
  repoPath: string;
23
32
  flowPath: string;
33
+ projectSlug: string;
34
+ flowSlug: string;
35
+ isDefault: boolean;
36
+ icon?: string;
24
37
  valid?: boolean;
25
38
  lastModified?: number;
26
39
  }
@@ -50,18 +63,39 @@ export function defaultRegistryPath(): string {
50
63
  return join(seeflowHome(), 'registry.json');
51
64
  }
52
65
 
53
- export function slugify(name: string): string {
54
- const slug = name
55
- .toLowerCase()
56
- .replace(/[^a-z0-9]+/g, '-')
57
- .replace(/^-+|-+$/g, '');
58
- return slug || 'demo';
59
- }
66
+ /**
67
+ * Production load-time predicate: only accept entries whose project still
68
+ * exposes a `seeflow.json` manifest at `repoPath` and whose `flowPath` matches
69
+ * the manifest layout (`flows/<id>/flow.json`). Anything else is a stale or
70
+ * pre-manifest registration and is dropped silently.
71
+ *
72
+ * Wire this into `createRegistry({ isLoadableEntry })` from every production
73
+ * call site (CLI, server). Tests construct registries without the predicate
74
+ * so they can use fake `/tmp/...` repo paths without scaffolding files.
75
+ */
76
+ export const manifestOnlyEntryFilter = (entry: FlowEntry): boolean => {
77
+ if (!existsSync(join(entry.repoPath, 'seeflow.json'))) return false;
78
+ if (entry.flowPath === 'flow.json') return false;
79
+ return true;
80
+ };
60
81
 
61
82
  const OWN_WRITE_RING_SIZE = 4;
62
83
 
63
- export function createRegistry(options: { path?: string } = {}): Registry {
84
+ export interface CreateRegistryOptions {
85
+ path?: string;
86
+ /**
87
+ * Optional predicate run on every persisted entry at load time. Entries that
88
+ * return `false` are silently dropped from the in-memory map (and rewritten
89
+ * out of registry.json on the next persist). Production uses this to ignore
90
+ * any entry whose project no longer has a `seeflow.json` manifest — legacy
91
+ * support has been dropped, manifest layout is the only accepted layout.
92
+ */
93
+ isLoadableEntry?: (entry: FlowEntry) => boolean;
94
+ }
95
+
96
+ export function createRegistry(options: CreateRegistryOptions = {}): Registry {
64
97
  const path = options.path ?? defaultRegistryPath();
98
+ const isLoadableEntry = options.isLoadableEntry;
65
99
  const entries = new Map<string, FlowEntry>();
66
100
  const writtenHashes: string[] = [];
67
101
  const listeners = new Set<() => void>();
@@ -116,6 +150,31 @@ export function createRegistry(options: { path?: string } = {}): Registry {
116
150
  if (entry.description !== undefined && typeof entry.description !== 'string') {
117
151
  entry.description = undefined;
118
152
  }
153
+ // Migration shim for legacy entries written before US-002. Derive
154
+ // projectSlug / flowSlug / isDefault so single-flow repos keep
155
+ // working until they are re-scanned through registerProject(). The
156
+ // scanner overwrites these on the next session anyway.
157
+ if (typeof entry.projectSlug !== 'string') {
158
+ entry.projectSlug = slugify(entry.name);
159
+ }
160
+ if (typeof entry.flowSlug !== 'string') {
161
+ entry.flowSlug = 'main';
162
+ }
163
+ if (typeof entry.isDefault !== 'boolean') {
164
+ entry.isDefault = true;
165
+ }
166
+ if (entry.icon !== undefined && typeof entry.icon !== 'string') {
167
+ entry.icon = undefined;
168
+ }
169
+ // Always re-derive slug from project + flow so the stored slug
170
+ // and the on-disk fields cannot drift apart.
171
+ entry.slug = `${entry.projectSlug}/${entry.flowSlug}`;
172
+ if (isLoadableEntry && !isLoadableEntry(entry)) {
173
+ console.warn(
174
+ `[registry] dropping entry ${entry.id} (${entry.slug}) — not loadable under the manifest-only contract`,
175
+ );
176
+ continue;
177
+ }
119
178
  entries.set(entry.id, entry);
120
179
  }
121
180
  }
@@ -163,14 +222,6 @@ export function createRegistry(options: { path?: string } = {}): Registry {
163
222
  return undefined;
164
223
  };
165
224
 
166
- const uniqueSlug = (base: string): string => {
167
- const taken = new Set([...entries.values()].map((e) => e.slug));
168
- if (!taken.has(base)) return base;
169
- let n = 2;
170
- while (taken.has(`${base}-${n}`)) n++;
171
- return `${base}-${n}`;
172
- };
173
-
174
225
  return {
175
226
  path,
176
227
  list: () => {
@@ -202,16 +253,23 @@ export function createRegistry(options: { path?: string } = {}): Registry {
202
253
  upsert(input) {
203
254
  const lastModified = input.lastModified ?? Date.now();
204
255
  const valid = input.valid ?? true;
256
+ const slug = `${input.projectSlug}/${input.flowSlug}`;
205
257
  const existing = findByRepoPathAndFlowPath(input.repoPath, input.flowPath);
206
258
  if (existing) {
207
259
  // input.description reflects the current flow.json on every call —
208
260
  // when an author removes the description, we drop it from the entry
209
- // too (JSON.stringify skips undefined values on persist).
261
+ // too (JSON.stringify skips undefined values on persist). Same shape
262
+ // applies to icon.
210
263
  const updated: FlowEntry = {
211
264
  ...existing,
265
+ slug,
212
266
  name: input.name,
213
267
  description: input.description,
214
268
  flowPath: input.flowPath,
269
+ projectSlug: input.projectSlug,
270
+ flowSlug: input.flowSlug,
271
+ isDefault: input.isDefault,
272
+ icon: input.icon,
215
273
  lastModified,
216
274
  valid,
217
275
  };
@@ -220,7 +278,6 @@ export function createRegistry(options: { path?: string } = {}): Registry {
220
278
  return updated;
221
279
  }
222
280
  const id = shortId();
223
- const slug = uniqueSlug(slugify(input.name));
224
281
  const entry: FlowEntry = {
225
282
  id,
226
283
  slug,
@@ -228,6 +285,10 @@ export function createRegistry(options: { path?: string } = {}): Registry {
228
285
  description: input.description,
229
286
  repoPath: input.repoPath,
230
287
  flowPath: input.flowPath,
288
+ projectSlug: input.projectSlug,
289
+ flowSlug: input.flowSlug,
290
+ isDefault: input.isDefault,
291
+ icon: input.icon,
231
292
  lastModified,
232
293
  valid,
233
294
  };
@@ -0,0 +1,41 @@
1
+ import type { FlowEntry, Registry } from './registry.ts';
2
+
3
+ /**
4
+ * Discriminated result of {@link resolveProjectFlow}.
5
+ *
6
+ * The error arm uses a stable `code` field so HTTP handlers can echo the same
7
+ * string back to clients (`{ ok:false, error: result.code }`) without an
8
+ * intermediate mapping table — see the route migration in US-007 onward.
9
+ */
10
+ export type ResolveProjectFlowResult =
11
+ | { kind: 'ok'; entry: FlowEntry }
12
+ | { kind: 'error'; code: 'project-not-found' | 'flow-not-found' };
13
+
14
+ /**
15
+ * Resolve a `(projectSlug, flowSlug)` pair to its registered {@link FlowEntry}.
16
+ *
17
+ * Splits the lookup into two stages so HTTP handlers can return a precise 404:
18
+ * 1. If no entry shares the given `projectSlug`, the project itself doesn't
19
+ * exist → `project-not-found`.
20
+ * 2. If some entry shares the project but none matches `flowSlug`, the
21
+ * project is registered but the flow isn't → `flow-not-found`.
22
+ *
23
+ * The single canonical caller is the route layer (US-007 / US-008); CLI code
24
+ * keeps using `registry.resolve(idOrSlug)` because it accepts an ambiguous
25
+ * `<flowId-or-slug>` argument.
26
+ */
27
+ export function resolveProjectFlow(
28
+ registry: Registry,
29
+ projectSlug: string,
30
+ flowSlug: string,
31
+ ): ResolveProjectFlowResult {
32
+ const projectEntries = registry.list().filter((e) => e.projectSlug === projectSlug);
33
+ if (projectEntries.length === 0) {
34
+ return { kind: 'error', code: 'project-not-found' };
35
+ }
36
+ const entry = projectEntries.find((e) => e.flowSlug === flowSlug);
37
+ if (!entry) {
38
+ return { kind: 'error', code: 'flow-not-found' };
39
+ }
40
+ return { kind: 'ok', entry };
41
+ }
package/src/schema.ts CHANGED
@@ -748,3 +748,57 @@ export const StyleSchema = z
748
748
  export type Style = z.infer<typeof StyleSchema>;
749
749
  export type NodeStyle = z.infer<typeof NodeStyleSchema>;
750
750
  export type ConnectorStyleEntry = z.infer<typeof ConnectorStyleEntrySchema>;
751
+
752
+ // =============================================================================
753
+ // Seeflow manifest — top-level descriptor for a multi-flow project.
754
+ // Lives at <project>/seeflow.json. Declares the flows the project hosts and
755
+ // which one to open by default. The scanner (project-scanner.ts) turns this
756
+ // plus the flows/<id>/flow.json files into ScannedFlow entries the registry
757
+ // can consume.
758
+ // =============================================================================
759
+
760
+ // Flow ids are URL-safe and folder-safe: lowercase alphanumerics + dashes,
761
+ // must start with an alphanumeric character. Same pattern enforced by the
762
+ // manifest CRUD endpoints (POST/PATCH /api/projects/:project/flows[/:flow]).
763
+ export const FlowIdPattern = /^[a-z0-9][a-z0-9-]*$/;
764
+
765
+ const SeeflowManifestFlowEntrySchema = z.object({
766
+ id: z.string().regex(FlowIdPattern, {
767
+ message: 'flow id must match /^[a-z0-9][a-z0-9-]*$/',
768
+ }),
769
+ name: z.string().min(1),
770
+ icon: z.string().min(1).optional(),
771
+ });
772
+
773
+ export const SeeflowManifestSchema = z
774
+ .object({
775
+ version: z.literal(1),
776
+ name: z.string().min(1),
777
+ description: z.string().optional(),
778
+ defaultFlow: z.string().min(1),
779
+ flows: z.array(SeeflowManifestFlowEntrySchema).min(1),
780
+ })
781
+ .strict()
782
+ .superRefine((manifest, ctx) => {
783
+ const seen = new Set<string>();
784
+ manifest.flows.forEach((flow, idx) => {
785
+ if (seen.has(flow.id)) {
786
+ ctx.addIssue({
787
+ code: z.ZodIssueCode.custom,
788
+ path: ['flows', idx, 'id'],
789
+ message: `duplicate flow id "${flow.id}"`,
790
+ });
791
+ }
792
+ seen.add(flow.id);
793
+ });
794
+ if (!manifest.flows.some((flow) => flow.id === manifest.defaultFlow)) {
795
+ ctx.addIssue({
796
+ code: z.ZodIssueCode.custom,
797
+ path: ['defaultFlow'],
798
+ message: `defaultFlow "${manifest.defaultFlow}" does not match any entry in flows[]`,
799
+ });
800
+ }
801
+ });
802
+
803
+ export type SeeflowManifest = z.infer<typeof SeeflowManifestSchema>;
804
+ export type SeeflowManifestFlowEntry = z.infer<typeof SeeflowManifestFlowEntrySchema>;
package/src/server.ts CHANGED
@@ -4,13 +4,14 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
4
4
  import { Hono } from 'hono';
5
5
  import { serveStatic } from 'hono/bun';
6
6
  import { type ProxyFacade, createApi } from './api.ts';
7
+ import { createCorsMiddleware } from './cors.ts';
7
8
  import { createDemoRouter } from './demo.ts';
8
9
  import { type EventBus, createEventBus } from './events.ts';
9
10
  import { createMcpServer } from './mcp.ts';
10
11
  import { seeflowHome } from './paths.ts';
11
12
  import { type ProcessSpawner, defaultProcessSpawner } from './process-spawner.ts';
12
13
  import { type RegistryWatcher, createRegistryWatcher } from './registry-watcher.ts';
13
- import { type Registry, createRegistry } from './registry.ts';
14
+ import { type Registry, createRegistry, manifestOnlyEntryFilter } from './registry.ts';
14
15
  import type { Spawner } from './shellout.ts';
15
16
  import { type StatusRunner, createStatusRunner } from './status-runner.ts';
16
17
  import { type FlowWatcher, createWatcher } from './watcher.ts';
@@ -54,6 +55,19 @@ export interface CreateAppOptions {
54
55
  /** Inject a ProxyFacade — tests use this to short-circuit runPlay /
55
56
  * runReset / stopAllPlays and assert call order. */
56
57
  proxy?: ProxyFacade;
58
+ /** Per-process token gating `Origin: null` requests (sandboxed MCP App
59
+ * iframe). Generated at studio boot; delivered to the iframe via
60
+ * `widgetState.backendToken`. Undefined disables the null-origin path —
61
+ * null-origin requests are then always rejected. */
62
+ token?: string;
63
+ /** Reachable loopback URL of this Hono server, e.g.
64
+ * `http://127.0.0.1:54321`. Forwarded to canvas-bearing MCP tool
65
+ * handlers (via `createMcpServer`) so they can attach it to the
66
+ * iframe's widgetState as `backendUrl`. Read by closure inside
67
+ * `app.all('/mcp', ...)` so callers can mutate the options after
68
+ * `Bun.serve` binds — useful for the ephemeral-port boot in
69
+ * `mcp-shim.ts` where the URL isn't known until the server is up. */
70
+ httpUrl?: string;
57
71
  }
58
72
 
59
73
  const DEFAULT_VITE_DEV_URL = 'http://localhost:5173';
@@ -71,7 +85,7 @@ export function createApp(options: CreateAppOptions = {}): Hono {
71
85
  const mode = options.mode ?? inferMode();
72
86
  const viteDevUrl = options.viteDevUrl ?? DEFAULT_VITE_DEV_URL;
73
87
  const staticRoot = options.staticRoot ?? DEFAULT_STATIC_ROOT;
74
- const registry = options.registry ?? createRegistry();
88
+ const registry = options.registry ?? createRegistry({ isLoadableEntry: manifestOnlyEntryFilter });
75
89
  const events = options.events ?? createEventBus();
76
90
  const watcher = options.disableWatcher
77
91
  ? undefined
@@ -92,6 +106,11 @@ export function createApp(options: CreateAppOptions = {}): Hono {
92
106
 
93
107
  const app = new Hono();
94
108
 
109
+ // CORS + token gate runs first so every downstream route inherits the
110
+ // null-origin rule. No-ops on requests without an Origin header (CLI
111
+ // calls, integration tests, top-level navigation).
112
+ app.use('*', createCorsMiddleware(options.token));
113
+
95
114
  app.get('/health', (c) => c.json({ ok: true }));
96
115
 
97
116
  // `/healthz` is the readiness probe used by the Docker entrypoint and any
@@ -155,6 +174,8 @@ export function createApp(options: CreateAppOptions = {}): Hono {
155
174
  const mcpServer = createMcpServer({
156
175
  registry,
157
176
  watcher,
177
+ token: options.token,
178
+ httpUrl: options.httpUrl,
158
179
  });
159
180
  await mcpServer.connect(transport);
160
181
  try {
@@ -211,7 +232,7 @@ export function serve(options: ServeOptions = {}) {
211
232
  }
212
233
 
213
234
  if (import.meta.main) {
214
- const registry = createRegistry();
235
+ const registry = createRegistry({ isLoadableEntry: manifestOnlyEntryFilter });
215
236
  const events = createEventBus();
216
237
  const statusRunner = createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
217
238
  const server = serve({ registry, events, statusRunner });