@tuongaz/seeflow 0.1.76 → 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.
- package/README.md +40 -0
- package/dist/web/assets/{architectureDiagram-3BPJPVTR-CVOsL8en.js → architectureDiagram-3BPJPVTR-id0XTZQC.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-C4ln3pHP.js → blockDiagram-GPEHLZMM-Cjvfg0ZP.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-COBq_O0m.js → c4Diagram-AAUBKEIU-Dyq-0e8Q.js} +1 -1
- package/dist/web/assets/channel-Ajb6KiL3.js +1 -0
- package/dist/web/assets/{chart-DD5RfLtm.js → chart-DuTGW-Dj.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-Bdm-YMMv.js → chunk-2J33WTMH-DsD65OzD.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-BV9NRSCp.js → chunk-4BX2VUAB-BpytKE8P.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-D-Xe7eUY.js → chunk-55IACEB6-DIILAUq9.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-DB6EZ4tw.js → chunk-727SXJPM-C4ih-gTo.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DDlp8cdl.js → chunk-AQP2D5EJ-BsYoWdVM.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-qmkSV1r1.js → chunk-FMBD7UC4-Db6L0z4p.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-BFkM9OZ5.js → chunk-ND2GUHAM-BNLqZYMx.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-Cye5hUth.js → chunk-QZHKN3VN-DL5PK45j.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-Cgw6ezRo.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Cgw6ezRo.js +1 -0
- package/dist/web/assets/{code-block-B2Jj4pwe.js → code-block-C1SJv-Al.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-B8Q15-m-.js → cose-bilkent-S5V4N54A-ChX5nR0f.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-BEYccL0v.js → dagre-BM42HDAG-BXeL3fEN.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-BHPvhIzV.js → diagram-2AECGRRQ-B6WtmEP-.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-Csqbxt3l.js → diagram-5GNKFQAL-SXs7ALwM.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-BR6CIJyT.js → diagram-KO2AKTUF-D5zylPYo.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CNtClLgB.js → diagram-LMA3HP47-CByIUlQF.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-6C-uLOFp.js → diagram-OG6HWLK6-BH1MfUqV.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-WbZOwkSY.js → erDiagram-TEJ5UH35-BOOnRFBh.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-DkXzWb1H.js → flowDiagram-I6XJVG4X-BynWDHJP.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-CUvI4hDd.js → ganttDiagram-6RSMTGT7-Cgq_djyN.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-DQ5Z5DTr.js → gitGraphDiagram-PVQCEYII-ciGSgmfT.js} +1 -1
- package/dist/web/assets/index-DiakpHyc.js +8619 -0
- package/dist/web/assets/{index-DljfurDC.css → index-fl8DS9WO.css} +1 -1
- package/dist/web/assets/{index.es-DU_7oRoK.js → index.es-C7TtaIfa.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-CVl3bX4h.js → infoDiagram-5YYISTIA-DqMb3_c-.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CQs9MZtd.js → ishikawaDiagram-YF4QCWOH-CAO6KqQU.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-B9emRTSL.js → journeyDiagram-JHISSGLW-Di8MsLTo.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DO9eBtyx.js → jspdf.es.min-Cq4dY-lT.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-CU4PXBkO.js → kanban-definition-UN3LZRKU-ClOmVNcX.js} +1 -1
- package/dist/web/assets/{linear-Djd98ym6.js → linear-B3OKBKaT.js} +1 -1
- package/dist/web/assets/{markdown-Dtbihta2.js → markdown-Dg8NEx1K.js} +1 -1
- package/dist/web/assets/{mermaid.core--5B4uu7H.js → mermaid.core-Bw-m7bH-.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-DBa-qOvW.js → mindmap-definition-RKZ34NQL-CUBA1zfc.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-BgO03eBD.js → pieDiagram-4H26LBE5-Dux5HvSU.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-D5qnQqxO.js → quadrantDiagram-W4KKPZXB-DU3gQGo3.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CvFTU_QP.js → requirementDiagram-4Y6WPE33-CD3A_U9j.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-BSwt6J0r.js → sankeyDiagram-5OEKKPKP-Cd4mc26P.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-B6xwt7gx.js → sequenceDiagram-3UESZ5HK-Da0iOMgq.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-B4aBLe9A.js → stateDiagram-AJRCARHV-P94LaOD2.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU--JLHF28o.js +1 -0
- package/dist/web/assets/{time-VmDaOXzG.js → time-0JEErjjJ.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-Bp46ZbZu.js → timeline-definition-PNZ67QCA-BqAYomix.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-ObB0ozDF.js → vennDiagram-CIIHVFJN-BWuPhfIM.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-DZQGppGX.js → wardley-L42UT6IY-iiGkgUQj.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-B3dCDfV5.js → wardleyDiagram-YWT4CUSO-CtqzFQXL.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-CpxmkRi4.js → xychartDiagram-2RQKCTM6-BGrOXndI.js} +1 -1
- package/dist/web/index.html +2 -2
- package/examples/component-showcase/seeflow.json +6 -0
- package/examples/ecommerce-platform/seeflow.json +6 -0
- package/examples/order-pipeline/seeflow.json +6 -0
- package/package.json +1 -1
- package/src/api.ts +739 -94
- package/src/cli-e2e.ts +24 -13
- package/src/cli-helpers.ts +26 -0
- package/src/cli-manifest.ts +330 -87
- package/src/cli-ops.ts +56 -2
- package/src/cli.ts +228 -81
- package/src/cors.ts +93 -0
- package/src/jq-filter.ts +253 -0
- package/src/mcp-shim.ts +114 -7
- package/src/mcp-ui.ts +126 -0
- package/src/mcp.ts +258 -97
- package/src/node-files.ts +18 -7
- package/src/operations.ts +68 -32
- package/src/project-scanner.ts +105 -0
- package/src/registry.ts +79 -18
- package/src/route-resolve.ts +41 -0
- package/src/schema.ts +54 -0
- package/src/server.ts +24 -3
- package/src/slugify.ts +16 -0
- package/dist/web/assets/channel-BpDUSI6-.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-po1qHJgX.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-po1qHJgX.js +0 -1
- package/dist/web/assets/index-DJa2Qm_q.js +0 -8614
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D1iGL3Mj.js +0 -1
- /package/examples/component-showcase/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/chart/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/counter/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/actions/refresh.ts +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/fetcher/spec.json +0 -0
- /package/examples/component-showcase/{nodes → flows/main/nodes}/form/spec.json +0 -0
- /package/examples/component-showcase/{style.json → flows/main/style.json} +0 -0
- /package/examples/ecommerce-platform/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-3zFtHg6ENc/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-5F424NWbEu/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-CbwYqb7NfB/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-XwygzfKPZ5/view.html +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-fkptXw7uvs/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-kwBY8YPmYM/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-mPqan8rFYN/detail.md +0 -0
- /package/examples/ecommerce-platform/{nodes → flows/main/nodes}/node-yKrg9DV5fJ/detail.md +0 -0
- /package/examples/ecommerce-platform/{scripts → flows/main/scripts}/play.ts +0 -0
- /package/examples/ecommerce-platform/{style.json → flows/main/style.json} +0 -0
- /package/examples/order-pipeline/{flow.json → flows/main/flow.json} +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-GXTKUcE3ye/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-XKIyds0TDg/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-YOYiHJpY0i/detail.md +0 -0
- /package/examples/order-pipeline/{nodes → flows/main/nodes}/node-zUIH7WFnhK/detail.md +0 -0
- /package/examples/order-pipeline/{scripts → flows/main/scripts}/play.ts +0 -0
- /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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1179
|
+
if (existsSync(manifestPath) || existsSync(legacyFlowPath) || existsSync(mainFlowPath)) {
|
|
1174
1180
|
return { kind: 'alreadyExists', path: folderPath };
|
|
1175
1181
|
}
|
|
1176
1182
|
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1416
|
-
// idAlreadyExists branch returns before any writeNodeFile, so the rmdir
|
|
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
|
-
// `<
|
|
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
|
-
// `<
|
|
1563
|
-
// in sync. mergeNodeUpdates already put data.spec on the
|
|
1564
|
-
// the post-mutation ResolvedFlowSchema parse; splitFlow
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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 });
|