@tuongaz/seeflow 0.1.40 → 0.1.42

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 (38) hide show
  1. package/README.md +2 -15
  2. package/dist/web/assets/{index-DTNk6GGk.js → index-BPUoNIBm.js} +1541 -1541
  3. package/dist/web/assets/{index-BwdVgB2y.css → index-BlkUOp7f.css} +1 -1
  4. package/dist/web/assets/{index.es-D_iCCj4R.js → index.es-mje3R_63.js} +1 -1
  5. package/dist/web/assets/{jspdf.es.min-C9FG4HQT.js → jspdf.es.min-DX3imOs2.js} +3 -3
  6. package/dist/web/index.html +2 -2
  7. package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
  8. package/examples/ecommerce-platform/.seeflow/style.json +10 -10
  9. package/examples/order-pipeline/.seeflow/flow.json +17 -17
  10. package/examples/order-pipeline/.seeflow/style.json +4 -4
  11. package/package.json +1 -1
  12. package/src/api.ts +101 -14
  13. package/src/atomic-write.ts +16 -0
  14. package/src/cli-e2e.ts +420 -0
  15. package/src/cli-helpers.ts +65 -0
  16. package/src/cli.ts +371 -17
  17. package/src/mcp.ts +116 -23
  18. package/src/merge.ts +1 -1
  19. package/src/node-files.ts +45 -0
  20. package/src/operations.ts +304 -98
  21. package/src/proxy.ts +35 -6
  22. package/src/registry.ts +2 -1
  23. package/src/schema.ts +31 -25
  24. package/src/short-id.ts +24 -0
  25. package/src/status-runner.ts +9 -8
  26. package/src/watcher.ts +14 -14
  27. /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
  28. /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
  29. /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
  30. /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
  31. /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
  32. /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
  33. /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
  34. /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
  35. /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
  36. /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
  37. /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
  38. /package/examples/order-pipeline/.seeflow/{details/fulfillment-service.md → nodes/node-zUIH7WFnhK/detail.md} +0 -0
@@ -3,7 +3,7 @@
3
3
  "name": "E-Commerce Platform",
4
4
  "nodes": [
5
5
  {
6
- "id": "client",
6
+ "id": "node-cQOUPXanaX",
7
7
  "type": "shapeNode",
8
8
  "data": {
9
9
  "shape": "user",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  {
15
- "id": "api-gateway",
15
+ "id": "node-CbwYqb7NfB",
16
16
  "type": "playNode",
17
17
  "data": {
18
18
  "name": "API Gateway",
@@ -21,7 +21,7 @@
21
21
  "kind": "request"
22
22
  },
23
23
  "description": "Auth, rate-limiting, routing.",
24
- "detail": "file://details/api-gateway.md",
24
+ "detail": "file://nodes/node-CbwYqb7NfB/detail.md",
25
25
  "playAction": {
26
26
  "kind": "script",
27
27
  "interpreter": "bun",
@@ -33,7 +33,7 @@
33
33
  }
34
34
  },
35
35
  {
36
- "id": "auth-service",
36
+ "id": "node-3zFtHg6ENc",
37
37
  "type": "stateNode",
38
38
  "data": {
39
39
  "name": "Auth Service",
@@ -42,11 +42,11 @@
42
42
  "kind": "request"
43
43
  },
44
44
  "description": "JWT issuance + OAuth2 / OIDC.",
45
- "detail": "file://details/auth-service.md"
45
+ "detail": "file://nodes/node-3zFtHg6ENc/detail.md"
46
46
  }
47
47
  },
48
48
  {
49
- "id": "product-service",
49
+ "id": "node-kwBY8YPmYM",
50
50
  "type": "stateNode",
51
51
  "data": {
52
52
  "name": "Product Catalog",
@@ -55,11 +55,11 @@
55
55
  "kind": "request"
56
56
  },
57
57
  "description": "SKUs, variants, pricing, full-text search.",
58
- "detail": "file://details/product-service.md"
58
+ "detail": "file://nodes/node-kwBY8YPmYM/detail.md"
59
59
  }
60
60
  },
61
61
  {
62
- "id": "cart-service",
62
+ "id": "node-5F424NWbEu",
63
63
  "type": "playNode",
64
64
  "data": {
65
65
  "name": "Cart Service",
@@ -68,7 +68,7 @@
68
68
  "kind": "request"
69
69
  },
70
70
  "description": "Add/remove items, apply coupons, checkout.",
71
- "detail": "file://details/cart-service.md",
71
+ "detail": "file://nodes/node-5F424NWbEu/detail.md",
72
72
  "playAction": {
73
73
  "kind": "script",
74
74
  "interpreter": "bun",
@@ -80,7 +80,7 @@
80
80
  }
81
81
  },
82
82
  {
83
- "id": "order-service",
83
+ "id": "node-yKrg9DV5fJ",
84
84
  "type": "playNode",
85
85
  "data": {
86
86
  "name": "Order Service",
@@ -88,8 +88,8 @@
88
88
  "stateSource": {
89
89
  "kind": "event"
90
90
  },
91
- "description": "pending \u2192 confirmed \u2192 shipped \u2192 delivered.",
92
- "detail": "file://details/order-service.md",
91
+ "description": "pending confirmed shipped delivered.",
92
+ "detail": "file://nodes/node-yKrg9DV5fJ/detail.md",
93
93
  "playAction": {
94
94
  "kind": "script",
95
95
  "interpreter": "bun",
@@ -101,7 +101,7 @@
101
101
  }
102
102
  },
103
103
  {
104
- "id": "payment-service",
104
+ "id": "node-mPqan8rFYN",
105
105
  "type": "stateNode",
106
106
  "data": {
107
107
  "name": "Payment Service",
@@ -110,11 +110,11 @@
110
110
  "kind": "event"
111
111
  },
112
112
  "description": "Stripe + PayPal gateway integration.",
113
- "detail": "file://details/payment-service.md"
113
+ "detail": "file://nodes/node-mPqan8rFYN/detail.md"
114
114
  }
115
115
  },
116
116
  {
117
- "id": "notification-service",
117
+ "id": "node-fkptXw7uvs",
118
118
  "type": "stateNode",
119
119
  "data": {
120
120
  "name": "Notification Service",
@@ -123,96 +123,96 @@
123
123
  "kind": "event"
124
124
  },
125
125
  "description": "Email + SMS + push via AWS SES / SNS.",
126
- "detail": "file://details/notification-service.md"
126
+ "detail": "file://nodes/node-fkptXw7uvs/detail.md"
127
127
  }
128
128
  },
129
129
  {
130
- "id": "postgresql",
130
+ "id": "node-5SDiw3Wz6s",
131
131
  "type": "shapeNode",
132
132
  "data": {
133
133
  "shape": "database",
134
134
  "name": "PostgreSQL",
135
- "description": "Orders, users, products \u2014 primary OLTP store"
135
+ "description": "Orders, users, products primary OLTP store"
136
136
  }
137
137
  },
138
138
  {
139
- "id": "platform-health",
139
+ "id": "node-XwygzfKPZ5",
140
140
  "type": "htmlNode",
141
141
  "data": {
142
142
  "name": "Platform Health",
143
- "htmlPath": "scripts/platform-health.html"
143
+ "html": "file://nodes/node-XwygzfKPZ5/view.html"
144
144
  }
145
145
  }
146
146
  ],
147
147
  "connectors": [
148
148
  {
149
- "id": "c-client-api",
150
- "source": "client",
151
- "target": "api-gateway",
149
+ "id": "conn-4XKU3GcGPF",
150
+ "source": "node-cQOUPXanaX",
151
+ "target": "node-CbwYqb7NfB",
152
152
  "kind": "http",
153
153
  "method": "POST",
154
154
  "label": "REST API"
155
155
  },
156
156
  {
157
- "id": "c-api-auth",
158
- "source": "api-gateway",
159
- "target": "auth-service",
157
+ "id": "conn-OxNUxp7qB3",
158
+ "source": "node-CbwYqb7NfB",
159
+ "target": "node-3zFtHg6ENc",
160
160
  "kind": "http",
161
161
  "method": "POST",
162
162
  "label": "POST /auth"
163
163
  },
164
164
  {
165
- "id": "c-api-product",
166
- "source": "api-gateway",
167
- "target": "product-service",
165
+ "id": "conn-7HlJF6KVHx",
166
+ "source": "node-CbwYqb7NfB",
167
+ "target": "node-kwBY8YPmYM",
168
168
  "kind": "http",
169
169
  "method": "GET",
170
170
  "label": "GET /products"
171
171
  },
172
172
  {
173
- "id": "c-api-cart",
174
- "source": "api-gateway",
175
- "target": "cart-service",
173
+ "id": "conn-ORfUTiooia",
174
+ "source": "node-CbwYqb7NfB",
175
+ "target": "node-5F424NWbEu",
176
176
  "kind": "http",
177
177
  "method": "POST",
178
178
  "label": "POST /cart"
179
179
  },
180
180
  {
181
- "id": "c-cart-order",
182
- "source": "cart-service",
183
- "target": "order-service",
181
+ "id": "conn-EABXtQv89M",
182
+ "source": "node-5F424NWbEu",
183
+ "target": "node-yKrg9DV5fJ",
184
184
  "kind": "event",
185
185
  "eventName": "cart.checkout",
186
186
  "label": "cart.checkout"
187
187
  },
188
188
  {
189
- "id": "c-order-payment",
190
- "source": "order-service",
191
- "target": "payment-service",
189
+ "id": "conn-kjyg3RDDvu",
190
+ "source": "node-yKrg9DV5fJ",
191
+ "target": "node-mPqan8rFYN",
192
192
  "kind": "event",
193
193
  "eventName": "order.created",
194
194
  "label": "order.created"
195
195
  },
196
196
  {
197
- "id": "c-payment-notify",
198
- "source": "payment-service",
199
- "target": "notification-service",
197
+ "id": "conn-wqFq0shXO5",
198
+ "source": "node-mPqan8rFYN",
199
+ "target": "node-fkptXw7uvs",
200
200
  "kind": "event",
201
201
  "eventName": "payment.captured",
202
202
  "label": "payment.captured"
203
203
  },
204
204
  {
205
- "id": "c-order-pg",
206
- "source": "order-service",
207
- "target": "postgresql",
205
+ "id": "conn-8ftFXZvD4r",
206
+ "source": "node-yKrg9DV5fJ",
207
+ "target": "node-5SDiw3Wz6s",
208
208
  "kind": "http",
209
209
  "method": "POST",
210
210
  "label": "read/write"
211
211
  },
212
212
  {
213
- "id": "c-payment-pg",
214
- "source": "payment-service",
215
- "target": "postgresql",
213
+ "id": "conn-VTfjsOckF2",
214
+ "source": "node-mPqan8rFYN",
215
+ "target": "node-5SDiw3Wz6s",
216
216
  "kind": "http",
217
217
  "method": "POST",
218
218
  "label": "read/write"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "nodes": {
3
- "client": {
3
+ "node-cQOUPXanaX": {
4
4
  "position": {
5
5
  "x": 80,
6
6
  "y": 215.5
@@ -9,7 +9,7 @@
9
9
  "borderColor": "green",
10
10
  "borderSize": 1
11
11
  },
12
- "api-gateway": {
12
+ "node-CbwYqb7NfB": {
13
13
  "position": {
14
14
  "x": 320,
15
15
  "y": 227
@@ -18,7 +18,7 @@
18
18
  "borderColor": "green",
19
19
  "borderSize": 1
20
20
  },
21
- "auth-service": {
21
+ "node-3zFtHg6ENc": {
22
22
  "position": {
23
23
  "x": 660,
24
24
  "y": 50
@@ -27,7 +27,7 @@
27
27
  "borderColor": "green",
28
28
  "borderSize": 1
29
29
  },
30
- "product-service": {
30
+ "node-kwBY8YPmYM": {
31
31
  "position": {
32
32
  "x": 660,
33
33
  "y": 218
@@ -36,7 +36,7 @@
36
36
  "borderColor": "green",
37
37
  "borderSize": 1
38
38
  },
39
- "cart-service": {
39
+ "node-5F424NWbEu": {
40
40
  "position": {
41
41
  "x": 660,
42
42
  "y": 413
@@ -45,7 +45,7 @@
45
45
  "borderColor": "green",
46
46
  "borderSize": 1
47
47
  },
48
- "order-service": {
48
+ "node-yKrg9DV5fJ": {
49
49
  "position": {
50
50
  "x": 1000,
51
51
  "y": 413
@@ -54,7 +54,7 @@
54
54
  "borderColor": "green",
55
55
  "borderSize": 1
56
56
  },
57
- "payment-service": {
57
+ "node-mPqan8rFYN": {
58
58
  "position": {
59
59
  "x": 1340,
60
60
  "y": 349
@@ -63,7 +63,7 @@
63
63
  "borderColor": "green",
64
64
  "borderSize": 1
65
65
  },
66
- "notification-service": {
66
+ "node-fkptXw7uvs": {
67
67
  "position": {
68
68
  "x": 1680,
69
69
  "y": 339
@@ -72,7 +72,7 @@
72
72
  "borderColor": "green",
73
73
  "borderSize": 1
74
74
  },
75
- "postgresql": {
75
+ "node-5SDiw3Wz6s": {
76
76
  "position": {
77
77
  "x": 1720,
78
78
  "y": 507
@@ -81,7 +81,7 @@
81
81
  "borderColor": "green",
82
82
  "borderSize": 1
83
83
  },
84
- "platform-health": {
84
+ "node-XwygzfKPZ5": {
85
85
  "position": {
86
86
  "x": 1340,
87
87
  "y": 50
@@ -3,7 +3,7 @@
3
3
  "name": "Order Pipeline",
4
4
  "nodes": [
5
5
  {
6
- "id": "post-orders",
6
+ "id": "node-XKIyds0TDg",
7
7
  "type": "playNode",
8
8
  "data": {
9
9
  "name": "POST /orders",
@@ -12,7 +12,7 @@
12
12
  "kind": "request"
13
13
  },
14
14
  "description": "Creates order, kicks off the pipeline.",
15
- "detail": "file://details/post-orders.md",
15
+ "detail": "file://nodes/node-XKIyds0TDg/detail.md",
16
16
  "playAction": {
17
17
  "kind": "script",
18
18
  "interpreter": "bun",
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  {
27
- "id": "inventory-service",
27
+ "id": "node-GXTKUcE3ye",
28
28
  "type": "stateNode",
29
29
  "data": {
30
30
  "name": "Inventory Service",
@@ -33,12 +33,12 @@
33
33
  "kind": "event"
34
34
  },
35
35
  "description": "Reserves stock.",
36
- "detail": "file://details/inventory-service.md",
36
+ "detail": "file://nodes/node-GXTKUcE3ye/detail.md",
37
37
  "icon": "a-arrow-down-icon"
38
38
  }
39
39
  },
40
40
  {
41
- "id": "payment-service",
41
+ "id": "node-YOYiHJpY0i",
42
42
  "type": "stateNode",
43
43
  "data": {
44
44
  "name": "Payment Service",
@@ -47,11 +47,11 @@
47
47
  "kind": "event"
48
48
  },
49
49
  "description": "Charges card.",
50
- "detail": "file://details/payment-service.md"
50
+ "detail": "file://nodes/node-YOYiHJpY0i/detail.md"
51
51
  }
52
52
  },
53
53
  {
54
- "id": "fulfillment-service",
54
+ "id": "node-zUIH7WFnhK",
55
55
  "type": "stateNode",
56
56
  "data": {
57
57
  "name": "Fulfillment Service",
@@ -60,31 +60,31 @@
60
60
  "kind": "event"
61
61
  },
62
62
  "description": "Enqueues shipment.",
63
- "detail": "file://details/fulfillment-service.md"
63
+ "detail": "file://nodes/node-zUIH7WFnhK/detail.md"
64
64
  }
65
65
  }
66
66
  ],
67
67
  "connectors": [
68
68
  {
69
- "id": "c1",
70
- "source": "post-orders",
71
- "target": "inventory-service",
69
+ "id": "conn-jJjuWBfe3a",
70
+ "source": "node-XKIyds0TDg",
71
+ "target": "node-GXTKUcE3ye",
72
72
  "kind": "event",
73
73
  "eventName": "order.created",
74
74
  "label": "order.created"
75
75
  },
76
76
  {
77
- "id": "c2",
78
- "source": "inventory-service",
79
- "target": "payment-service",
77
+ "id": "conn-8DkPOzrnYo",
78
+ "source": "node-GXTKUcE3ye",
79
+ "target": "node-YOYiHJpY0i",
80
80
  "kind": "event",
81
81
  "eventName": "stock.reserved",
82
82
  "label": "stock.reserved"
83
83
  },
84
84
  {
85
- "id": "c3",
86
- "source": "payment-service",
87
- "target": "fulfillment-service",
85
+ "id": "conn-qp90Rd2cgw",
86
+ "source": "node-YOYiHJpY0i",
87
+ "target": "node-zUIH7WFnhK",
88
88
  "kind": "event",
89
89
  "eventName": "payment.captured",
90
90
  "label": "payment.captured"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "nodes": {
3
- "post-orders": {
3
+ "node-XKIyds0TDg": {
4
4
  "position": {
5
5
  "x": -181.96549037838943,
6
6
  "y": 138.95644990042385
@@ -9,7 +9,7 @@
9
9
  "borderColor": "green",
10
10
  "borderSize": 1
11
11
  },
12
- "inventory-service": {
12
+ "node-GXTKUcE3ye": {
13
13
  "position": {
14
14
  "x": 66.21484197841792,
15
15
  "y": -126.31035925520507
@@ -20,7 +20,7 @@
20
20
  "borderColor": "green",
21
21
  "borderSize": 1
22
22
  },
23
- "payment-service": {
23
+ "node-YOYiHJpY0i": {
24
24
  "position": {
25
25
  "x": 469.1422114437386,
26
26
  "y": 206.48855752663212
@@ -29,7 +29,7 @@
29
29
  "fontSize": 15,
30
30
  "borderColor": "green"
31
31
  },
32
- "fulfillment-service": {
32
+ "node-zUIH7WFnhK": {
33
33
  "position": {
34
34
  "x": 747.51321046746,
35
35
  "y": -37.78865629343234
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuongaz/seeflow",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Local studio that hosts file-defined demos as React Flow canvases wired to a running app via REST + SSE + Zod schema.",
5
5
  "keywords": [
6
6
  "seeflow",
package/src/api.ts CHANGED
@@ -15,14 +15,18 @@ import type { EventBus } from './events.ts';
15
15
  import { type LayoutOptions, computeLayout } from './layout.ts';
16
16
  import {
17
17
  ConnectorPatchBodySchema,
18
+ ConnectorsBulkBodySchema,
18
19
  CreateProjectBodySchema,
19
20
  NodePatchBodySchema,
21
+ NodesBulkBodySchema,
20
22
  PositionBodySchema,
21
23
  RegisterBodySchema,
22
24
  ReorderBodySchema,
23
25
  type ValidateBody,
24
26
  addConnectorImpl,
27
+ addConnectorsBulkImpl,
25
28
  addNodeImpl,
29
+ addNodesBulkImpl,
26
30
  createProjectImpl,
27
31
  deleteConnectorImpl,
28
32
  deleteFlowImpl,
@@ -134,7 +138,7 @@ function resolveProjectFile(
134
138
  return { kind: 'ok', absPath: realTarget, seeflowRoot: realRoot };
135
139
  }
136
140
 
137
- // Allowed extensions for /files/upload. Lowercased; matched after dropping the
141
+ // Allowed extensions for /nodes/:nodeId/files/upload. Lowercased; matched after dropping the
138
142
  // leading `.`. Stored as a Set so future expansion (PDF, video) is one-edit.
139
143
  const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
140
144
  const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
@@ -609,17 +613,23 @@ export function createApi(options: ApiOptions): Hono {
609
613
  return c.json({ ok: true, absPath: resolved.absPath });
610
614
  });
611
615
 
612
- // POST /api/projects/:id/files/upload — accept a multipart image upload and
613
- // persist it under `<project>/.seeflow/assets/`. The frontend (US-008 OS
614
- // drop) sends `file` (Blob) and optionally `filename` (the original OS name)
615
- // in a multipart form; we sanitize the filename to a lowercased slug,
616
- // dedupe with `-2`, `-3` suffixes inside the assets dir, and return the
617
- // demo-relative path. Allowlist + 5 MB cap guard against arbitrary uploads.
618
- api.post('/projects/:id/files/upload', async (c) => {
616
+ // POST /api/projects/:id/nodes/:nodeId/files/upload — accept a multipart
617
+ // image upload and persist it under `<project>/.seeflow/nodes/<nodeId>/`.
618
+ // Multipart shape: `file` (Blob) and optional `filename` (the original OS
619
+ // name). Allowlist + 5 MB cap guard against arbitrary uploads; the
620
+ // destination folder is scoped to the node, so delete_node's removeNodeDir
621
+ // cascade cleans up the asset along with the node row.
622
+ api.post('/projects/:id/nodes/:nodeId/files/upload', async (c) => {
619
623
  const projectId = c.req.param('id');
624
+ const nodeId = c.req.param('nodeId');
620
625
  const entry = registry.getById(projectId);
621
626
  if (!entry) return c.json({ error: 'unknown project' }, 404);
622
627
 
628
+ // node id shape: `node-<10 base62 chars>` (matches shortId() output).
629
+ if (!/^node-[A-Za-z0-9]{10}$/.test(nodeId)) {
630
+ return c.json({ error: 'invalid nodeId' }, 400);
631
+ }
632
+
623
633
  let form: FormData;
624
634
  try {
625
635
  form = await c.req.formData();
@@ -643,20 +653,20 @@ export function createApi(options: ApiOptions): Hono {
643
653
  return c.json({ error: 'invalid filename or extension' }, 400);
644
654
  }
645
655
 
646
- const assetsDir = join(entry.repoPath, '.seeflow', 'assets');
656
+ const nodeDir = join(entry.repoPath, '.seeflow', 'nodes', nodeId);
647
657
  try {
648
- mkdirSync(assetsDir, { recursive: true });
658
+ mkdirSync(nodeDir, { recursive: true });
649
659
  } catch (err) {
650
660
  return c.json(
651
661
  {
652
- error: `Failed to create assets dir: ${err instanceof Error ? err.message : String(err)}`,
662
+ error: `Failed to create node dir: ${err instanceof Error ? err.message : String(err)}`,
653
663
  },
654
664
  500,
655
665
  );
656
666
  }
657
667
 
658
- const finalName = pickUploadFilename(assetsDir, sanitized.base, sanitized.ext);
659
- const absPath = join(assetsDir, finalName);
668
+ const finalName = pickUploadFilename(nodeDir, sanitized.base, sanitized.ext);
669
+ const absPath = join(nodeDir, finalName);
660
670
  try {
661
671
  await Bun.write(absPath, fileField);
662
672
  } catch (err) {
@@ -666,7 +676,7 @@ export function createApi(options: ApiOptions): Hono {
666
676
  );
667
677
  }
668
678
 
669
- return c.json({ path: `assets/${finalName}` });
679
+ return c.json({ path: `nodes/${nodeId}/${finalName}` });
670
680
  });
671
681
 
672
682
  api.delete('/flows/:id', (c) => {
@@ -1058,6 +1068,45 @@ export function createApi(options: ApiOptions): Hono {
1058
1068
  }
1059
1069
  });
1060
1070
 
1071
+ // Bulk-create up to 100 nodes in one transactional write. Either the whole
1072
+ // batch lands and a single flow:reload broadcast fires, or nothing lands.
1073
+ // Intended for skill/LLM seeding where N singular calls would burn tokens
1074
+ // and round-trip latency. Per-item shape mirrors the singular endpoint.
1075
+ api.post('/flows/:id/nodes/bulk', async (c) => {
1076
+ const id = c.req.param('id');
1077
+
1078
+ let body: unknown;
1079
+ try {
1080
+ body = await c.req.json();
1081
+ } catch {
1082
+ return c.json({ error: 'Body must be valid JSON' }, 400);
1083
+ }
1084
+ const parsed = NodesBulkBodySchema.safeParse(body);
1085
+ if (!parsed.success) {
1086
+ return c.json({ error: 'Invalid bulk nodes body', issues: parsed.error.issues }, 400);
1087
+ }
1088
+
1089
+ const result = await addNodesBulkImpl({ registry, watcher }, id, parsed.data);
1090
+ switch (result.kind) {
1091
+ case 'ok':
1092
+ return c.json({ ok: true, nodes: result.data.nodes });
1093
+ case 'flowNotFound':
1094
+ return c.json({ error: 'unknown demo' }, 404);
1095
+ case 'fileNotFound':
1096
+ return c.json({ error: `Flow file not found: ${result.path}` }, 404);
1097
+ case 'badJson':
1098
+ return c.json({ error: `Flow file is not valid JSON: ${result.message}` }, 400);
1099
+ case 'badSchema':
1100
+ return c.json({ error: 'Flow failed schema validation', issues: result.issues }, 400);
1101
+ case 'duplicateIdInBatch':
1102
+ return c.json({ error: `Duplicate id in batch: ${result.id}` }, 400);
1103
+ case 'idAlreadyExists':
1104
+ return c.json({ error: `Node id already exists: ${result.id}` }, 400);
1105
+ case 'writeFailed':
1106
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
1107
+ }
1108
+ });
1109
+
1061
1110
  // DELETE a node and cascade-remove every connector with source === nodeId or
1062
1111
  // target === nodeId in the same atomic write. Final-ResolvedFlowSchema validation
1063
1112
  // is still run after the mutation — connector cascade closure means it
@@ -1165,6 +1214,44 @@ export function createApi(options: ApiOptions): Hono {
1165
1214
  }
1166
1215
  });
1167
1216
 
1217
+ // Bulk-create up to 100 connectors in one transactional write. Mirrors the
1218
+ // /nodes/bulk shape. Dangling source/target on any item rolls back the whole
1219
+ // batch via the post-mutation ResolvedFlowSchema parse.
1220
+ api.post('/flows/:id/connectors/bulk', async (c) => {
1221
+ const id = c.req.param('id');
1222
+
1223
+ let body: unknown;
1224
+ try {
1225
+ body = await c.req.json();
1226
+ } catch {
1227
+ return c.json({ error: 'Body must be valid JSON' }, 400);
1228
+ }
1229
+ const parsed = ConnectorsBulkBodySchema.safeParse(body);
1230
+ if (!parsed.success) {
1231
+ return c.json({ error: 'Invalid bulk connectors body', issues: parsed.error.issues }, 400);
1232
+ }
1233
+
1234
+ const result = await addConnectorsBulkImpl({ registry, watcher }, id, parsed.data);
1235
+ switch (result.kind) {
1236
+ case 'ok':
1237
+ return c.json({ ok: true, connectors: result.data.connectors });
1238
+ case 'flowNotFound':
1239
+ return c.json({ error: 'unknown demo' }, 404);
1240
+ case 'fileNotFound':
1241
+ return c.json({ error: `Flow file not found: ${result.path}` }, 404);
1242
+ case 'badJson':
1243
+ return c.json({ error: `Flow file is not valid JSON: ${result.message}` }, 400);
1244
+ case 'badSchema':
1245
+ return c.json({ error: 'Flow failed schema validation', issues: result.issues }, 400);
1246
+ case 'duplicateIdInBatch':
1247
+ return c.json({ error: `Duplicate id in batch: ${result.id}` }, 400);
1248
+ case 'idAlreadyExists':
1249
+ return c.json({ error: `Connector id already exists: ${result.id}` }, 400);
1250
+ case 'writeFailed':
1251
+ return c.json({ error: `Failed to write demo file: ${result.message}` }, 500);
1252
+ }
1253
+ });
1254
+
1168
1255
  // DELETE a connector. Just removes the entry from demo.connectors — node
1169
1256
  // deletion is what cascades, not connector deletion.
1170
1257
  api.delete('/flows/:id/connectors/:connId', async (c) => {
@@ -0,0 +1,16 @@
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+
3
+ export const writeFileAtomic = (filePath: string, content: string): void => {
4
+ const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
5
+ try {
6
+ writeFileSync(tempPath, content);
7
+ renameSync(tempPath, filePath);
8
+ } catch (err) {
9
+ try {
10
+ if (existsSync(tempPath)) unlinkSync(tempPath);
11
+ } catch {
12
+ // best-effort cleanup
13
+ }
14
+ throw err;
15
+ }
16
+ };