@tuongaz/seeflow 0.1.57 → 0.1.63

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 (44) hide show
  1. package/README.md +3 -3
  2. package/dist/web/assets/{index-CPlccVLi.js → index-DAP_yx-l.js} +354 -354
  3. package/dist/web/assets/{index.es-CYTTDW0Q.js → index.es-2bA-nRVD.js} +1 -1
  4. package/dist/web/assets/{jspdf.es.min-DOaPC0dc.js → jspdf.es.min-C7u0-VKd.js} +3 -3
  5. package/dist/web/index.html +1 -1
  6. package/examples/ecommerce-platform/{.seeflow/flow.json → flow.json} +3 -25
  7. package/examples/ecommerce-platform/{.seeflow/scripts → scripts}/play.ts +1 -1
  8. package/examples/order-pipeline/{.seeflow/flow.json → flow.json} +1 -10
  9. package/package.json +1 -1
  10. package/src/api.ts +83 -55
  11. package/src/cli-helpers.ts +6 -5
  12. package/src/cli-manifest.ts +129 -15
  13. package/src/cli.ts +106 -13
  14. package/src/diagram.ts +0 -1
  15. package/src/file-ref.ts +16 -15
  16. package/src/mcp.ts +96 -16
  17. package/src/merge.ts +0 -1
  18. package/src/node-files.ts +5 -5
  19. package/src/operations.ts +40 -101
  20. package/src/paths.ts +16 -0
  21. package/src/proxy.ts +13 -13
  22. package/src/schema-catalog.ts +114 -0
  23. package/src/schema.ts +110 -133
  24. package/src/server.ts +3 -5
  25. package/src/short-id.ts +24 -0
  26. package/src/status-runner.ts +3 -3
  27. package/src/watcher.ts +15 -27
  28. package/src/sdk-template.ts +0 -37
  29. package/src/sdk-writer.ts +0 -37
  30. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-3zFtHg6ENc/detail.md +0 -0
  31. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-5F424NWbEu/detail.md +0 -0
  32. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-CbwYqb7NfB/detail.md +0 -0
  33. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-XwygzfKPZ5/view.html +0 -0
  34. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-fkptXw7uvs/detail.md +0 -0
  35. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-kwBY8YPmYM/detail.md +0 -0
  36. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-mPqan8rFYN/detail.md +0 -0
  37. /package/examples/ecommerce-platform/{.seeflow/nodes → nodes}/node-yKrg9DV5fJ/detail.md +0 -0
  38. /package/examples/ecommerce-platform/{.seeflow/style.json → style.json} +0 -0
  39. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-GXTKUcE3ye/detail.md +0 -0
  40. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-XKIyds0TDg/detail.md +0 -0
  41. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-YOYiHJpY0i/detail.md +0 -0
  42. /package/examples/order-pipeline/{.seeflow/nodes → nodes}/node-zUIH7WFnhK/detail.md +0 -0
  43. /package/examples/order-pipeline/{.seeflow/scripts → scripts}/play.ts +0 -0
  44. /package/examples/order-pipeline/{.seeflow/style.json → style.json} +0 -0
@@ -16,7 +16,6 @@
16
16
  "type": "playNode",
17
17
  "data": {
18
18
  "name": "API Gateway",
19
- "kind": "gateway",
20
19
  "stateSource": {
21
20
  "kind": "request"
22
21
  },
@@ -25,9 +24,7 @@
25
24
  "playAction": {
26
25
  "kind": "script",
27
26
  "interpreter": "bun",
28
- "args": [
29
- "run"
30
- ],
27
+ "args": ["run"],
31
28
  "scriptPath": "scripts/play.ts"
32
29
  }
33
30
  }
@@ -37,7 +34,6 @@
37
34
  "type": "stateNode",
38
35
  "data": {
39
36
  "name": "Auth Service",
40
- "kind": "service",
41
37
  "stateSource": {
42
38
  "kind": "request"
43
39
  },
@@ -50,7 +46,6 @@
50
46
  "type": "stateNode",
51
47
  "data": {
52
48
  "name": "Product Catalog",
53
- "kind": "service",
54
49
  "stateSource": {
55
50
  "kind": "request"
56
51
  },
@@ -63,7 +58,6 @@
63
58
  "type": "playNode",
64
59
  "data": {
65
60
  "name": "Cart Service",
66
- "kind": "service",
67
61
  "stateSource": {
68
62
  "kind": "request"
69
63
  },
@@ -72,9 +66,7 @@
72
66
  "playAction": {
73
67
  "kind": "script",
74
68
  "interpreter": "bun",
75
- "args": [
76
- "run"
77
- ],
69
+ "args": ["run"],
78
70
  "scriptPath": "scripts/play.ts"
79
71
  }
80
72
  }
@@ -84,7 +76,6 @@
84
76
  "type": "playNode",
85
77
  "data": {
86
78
  "name": "Order Service",
87
- "kind": "worker",
88
79
  "stateSource": {
89
80
  "kind": "event"
90
81
  },
@@ -93,9 +84,7 @@
93
84
  "playAction": {
94
85
  "kind": "script",
95
86
  "interpreter": "bun",
96
- "args": [
97
- "run"
98
- ],
87
+ "args": ["run"],
99
88
  "scriptPath": "scripts/play.ts"
100
89
  }
101
90
  }
@@ -105,7 +94,6 @@
105
94
  "type": "stateNode",
106
95
  "data": {
107
96
  "name": "Payment Service",
108
- "kind": "worker",
109
97
  "stateSource": {
110
98
  "kind": "event"
111
99
  },
@@ -118,7 +106,6 @@
118
106
  "type": "stateNode",
119
107
  "data": {
120
108
  "name": "Notification Service",
121
- "kind": "worker",
122
109
  "stateSource": {
123
110
  "kind": "event"
124
111
  },
@@ -149,7 +136,6 @@
149
136
  "id": "conn-4XKU3GcGPF",
150
137
  "source": "node-cQOUPXanaX",
151
138
  "target": "node-CbwYqb7NfB",
152
- "kind": "http",
153
139
  "method": "POST",
154
140
  "label": "REST API"
155
141
  },
@@ -157,7 +143,6 @@
157
143
  "id": "conn-OxNUxp7qB3",
158
144
  "source": "node-CbwYqb7NfB",
159
145
  "target": "node-3zFtHg6ENc",
160
- "kind": "http",
161
146
  "method": "POST",
162
147
  "label": "POST /auth"
163
148
  },
@@ -165,7 +150,6 @@
165
150
  "id": "conn-7HlJF6KVHx",
166
151
  "source": "node-CbwYqb7NfB",
167
152
  "target": "node-kwBY8YPmYM",
168
- "kind": "http",
169
153
  "method": "GET",
170
154
  "label": "GET /products"
171
155
  },
@@ -173,7 +157,6 @@
173
157
  "id": "conn-ORfUTiooia",
174
158
  "source": "node-CbwYqb7NfB",
175
159
  "target": "node-5F424NWbEu",
176
- "kind": "http",
177
160
  "method": "POST",
178
161
  "label": "POST /cart"
179
162
  },
@@ -181,7 +164,6 @@
181
164
  "id": "conn-EABXtQv89M",
182
165
  "source": "node-5F424NWbEu",
183
166
  "target": "node-yKrg9DV5fJ",
184
- "kind": "event",
185
167
  "eventName": "cart.checkout",
186
168
  "label": "cart.checkout"
187
169
  },
@@ -189,7 +171,6 @@
189
171
  "id": "conn-kjyg3RDDvu",
190
172
  "source": "node-yKrg9DV5fJ",
191
173
  "target": "node-mPqan8rFYN",
192
- "kind": "event",
193
174
  "eventName": "order.created",
194
175
  "label": "order.created"
195
176
  },
@@ -197,7 +178,6 @@
197
178
  "id": "conn-wqFq0shXO5",
198
179
  "source": "node-mPqan8rFYN",
199
180
  "target": "node-fkptXw7uvs",
200
- "kind": "event",
201
181
  "eventName": "payment.captured",
202
182
  "label": "payment.captured"
203
183
  },
@@ -205,7 +185,6 @@
205
185
  "id": "conn-8ftFXZvD4r",
206
186
  "source": "node-yKrg9DV5fJ",
207
187
  "target": "node-5SDiw3Wz6s",
208
- "kind": "http",
209
188
  "method": "POST",
210
189
  "label": "read/write"
211
190
  },
@@ -213,7 +192,6 @@
213
192
  "id": "conn-VTfjsOckF2",
214
193
  "source": "node-mPqan8rFYN",
215
194
  "target": "node-5SDiw3Wz6s",
216
- "kind": "http",
217
195
  "method": "POST",
218
196
  "label": "read/write"
219
197
  }
@@ -1,2 +1,2 @@
1
1
  // Placeholder — wire up to your running app to make this demo playable.
2
- console.log("play triggered");
2
+ console.log('play triggered');
@@ -7,7 +7,6 @@
7
7
  "type": "playNode",
8
8
  "data": {
9
9
  "name": "POST /orders",
10
- "kind": "service",
11
10
  "stateSource": {
12
11
  "kind": "request"
13
12
  },
@@ -16,9 +15,7 @@
16
15
  "playAction": {
17
16
  "kind": "script",
18
17
  "interpreter": "bun",
19
- "args": [
20
- "run"
21
- ],
18
+ "args": ["run"],
22
19
  "scriptPath": "scripts/play.ts"
23
20
  }
24
21
  }
@@ -28,7 +25,6 @@
28
25
  "type": "stateNode",
29
26
  "data": {
30
27
  "name": "Inventory Service",
31
- "kind": "worker",
32
28
  "stateSource": {
33
29
  "kind": "event"
34
30
  },
@@ -42,7 +38,6 @@
42
38
  "type": "stateNode",
43
39
  "data": {
44
40
  "name": "Payment Service",
45
- "kind": "worker",
46
41
  "stateSource": {
47
42
  "kind": "event"
48
43
  },
@@ -55,7 +50,6 @@
55
50
  "type": "stateNode",
56
51
  "data": {
57
52
  "name": "Fulfillment Service",
58
- "kind": "worker",
59
53
  "stateSource": {
60
54
  "kind": "event"
61
55
  },
@@ -69,7 +63,6 @@
69
63
  "id": "conn-jJjuWBfe3a",
70
64
  "source": "node-XKIyds0TDg",
71
65
  "target": "node-GXTKUcE3ye",
72
- "kind": "event",
73
66
  "eventName": "order.created",
74
67
  "label": "order.created"
75
68
  },
@@ -77,7 +70,6 @@
77
70
  "id": "conn-8DkPOzrnYo",
78
71
  "source": "node-GXTKUcE3ye",
79
72
  "target": "node-YOYiHJpY0i",
80
- "kind": "event",
81
73
  "eventName": "stock.reserved",
82
74
  "label": "stock.reserved"
83
75
  },
@@ -85,7 +77,6 @@
85
77
  "id": "conn-qp90Rd2cgw",
86
78
  "source": "node-YOYiHJpY0i",
87
79
  "target": "node-zUIH7WFnhK",
88
- "kind": "event",
89
80
  "eventName": "payment.captured",
90
81
  "label": "payment.captured"
91
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuongaz/seeflow",
3
- "version": "0.1.57",
3
+ "version": "0.1.63",
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
@@ -37,8 +37,10 @@ import {
37
37
  stopAllPlays as defaultStopAllPlays,
38
38
  } from './proxy.ts';
39
39
  import type { Registry } from './registry.ts';
40
+ import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
40
41
  import { FlowSchema, ResolvedFlowSchema } from './schema.ts';
41
42
  import { type Spawner, defaultSpawner } from './shellout.ts';
43
+ import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
42
44
  import type { StatusRunner } from './status-runner.ts';
43
45
  import { readMergedFlow } from './watcher.ts';
44
46
  import type { FlowWatcher } from './watcher.ts';
@@ -76,14 +78,14 @@ const EMIT_STATUS_TO_EVENT = {
76
78
  const FilePathBodySchema = z.object({ path: z.string() });
77
79
 
78
80
  type ResolvedProjectFile =
79
- | { kind: 'ok'; absPath: string; seeflowRoot: string }
81
+ | { kind: 'ok'; absPath: string; projectRoot: string }
80
82
  | { kind: 'unknownProject' }
81
83
  | { kind: 'invalidPath'; reason: string }
82
84
  | { kind: 'fileMissing'; absPath: string };
83
85
 
84
86
  // Shared path-safety + filesystem resolution for project-scoped file routes.
85
87
  // Performs textual rejection of absolute paths / `..` traversal, then layered
86
- // realpath verification that the resolved file stays inside `<project>/.seeflow/`
88
+ // realpath verification that the resolved file stays inside the project root
87
89
  // (defense against symlink escapes). Returns the realpath of an existing file
88
90
  // on success, or `fileMissing` with the would-be absolute path so callers can
89
91
  // soft-fail with that path included for clipboard fallback.
@@ -98,15 +100,15 @@ function resolveProjectFile(
98
100
  const guard = validateRelativePath(relPath);
99
101
  if (guard.kind === 'invalid') return { kind: 'invalidPath', reason: guard.reason };
100
102
 
101
- const seeflowRoot = join(entry.repoPath, '.seeflow');
103
+ const projectRoot = entry.repoPath;
102
104
  let realRoot: string;
103
105
  try {
104
- realRoot = realpathSync(seeflowRoot);
106
+ realRoot = realpathSync(projectRoot);
105
107
  } catch {
106
- return { kind: 'fileMissing', absPath: resolve(seeflowRoot, relPath) };
108
+ return { kind: 'fileMissing', absPath: resolve(projectRoot, relPath) };
107
109
  }
108
110
 
109
- const target = resolve(seeflowRoot, relPath);
111
+ const target = resolve(projectRoot, relPath);
110
112
  let realTarget: string;
111
113
  try {
112
114
  realTarget = realpathSync(target);
@@ -119,7 +121,7 @@ function resolveProjectFile(
119
121
  return { kind: 'invalidPath', reason: 'path escapes project root' };
120
122
  }
121
123
 
122
- return { kind: 'ok', absPath: realTarget, seeflowRoot: realRoot };
124
+ return { kind: 'ok', absPath: realTarget, projectRoot: realRoot };
123
125
  }
124
126
 
125
127
  // Allowed extensions for /nodes/:nodeId/files/upload. Lowercased; matched after dropping the
@@ -174,8 +176,6 @@ export interface ApiOptions {
174
176
  * Tests use this to record call order across runPlay / runReset /
175
177
  * stopAllPlays and to drive each in isolation. */
176
178
  proxy?: ProxyFacade;
177
- /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
178
- projectBaseDir?: string;
179
179
  }
180
180
 
181
181
  /**
@@ -203,8 +203,7 @@ export function createApi(options: ApiOptions): Hono {
203
203
  const platform = options.platform ?? process.platform;
204
204
  const processSpawner = options.processSpawner;
205
205
  const proxy = options.proxy ?? defaultProxyFacade;
206
- const projectBaseDir = options.projectBaseDir;
207
- const ops = createOperations({ registry, watcher, projectBaseDir });
206
+ const ops = createOperations({ registry, watcher });
208
207
  const api = new Hono();
209
208
 
210
209
  api.post('/flows/register', async (c) => {
@@ -230,15 +229,6 @@ export function createApi(options: ApiOptions): Hono {
230
229
  return c.json({ error: 'Flow file is not valid JSON', detail: result.detail }, 400);
231
230
  case 'badSchema':
232
231
  return c.json({ error: 'Flow file failed schema validation', issues: result.issues }, 400);
233
- case 'sdkWriteFailed':
234
- return c.json(
235
- {
236
- error: `Failed to write SDK helper: ${result.message}`,
237
- id: result.id,
238
- slug: result.slug,
239
- },
240
- 500,
241
- );
242
232
  }
243
233
  });
244
234
 
@@ -298,8 +288,8 @@ export function createApi(options: ApiOptions): Hono {
298
288
  // POST /api/diagram/assemble — Phase 7a. The skill POSTs wiring + layout
299
289
  // and gets back the assembled demo (IDs normalized, dupes dropped, dangling
300
290
  // connectors removed, positions snapped to a 24px grid). Pure compute; the
301
- // skill writes the response to $TARGET/.seeflow/flow.json. No schema
302
- // validation here — call /demos/validate for that.
291
+ // skill writes the response to $TARGET/flow.json. No schema validation
292
+ // here — call /demos/validate for that.
303
293
  api.post('/diagram/assemble', async (c) => {
304
294
  let body: unknown;
305
295
  try {
@@ -413,17 +403,11 @@ export function createApi(options: ApiOptions): Hono {
413
403
  return c.json({ error: 'Body must be { flow, options? } or { nodes, edges, options? }' }, 400);
414
404
  });
415
405
 
416
- // POST /api/projects — UI-driven "Create new project" flow (US-020). Two
417
- // branches based on whether the target folder already has a SeeFlow
418
- // project set up at `<folderPath>/.seeflow/flow.json`:
419
- // 1. Existing setup: read + validate the on-disk demo and register it
420
- // as-is (no overwrite, no scaffolding). The user-supplied `name`
421
- // becomes the registry display name; the on-disk demo's `name` is
422
- // preserved on disk.
423
- // 2. Fresh scaffold: mkdir -p the folder + .seeflow/, write a default
424
- // scaffold flow.json keyed off `name`, and run the same SDK-emit
425
- // helper write the CLI register flow uses (a no-op for an empty
426
- // scaffold, but kept for parity).
406
+ // POST /api/projects — UI-driven "Create new project" flow (US-020).
407
+ // Scaffolds `<path>/flow.json` with the supplied name + optional
408
+ // description, then registers it. If the target already has a
409
+ // `flow.json`, returns 409 callers should use POST /api/flows/register
410
+ // instead.
427
411
  api.post('/projects', async (c) => {
428
412
  let body: unknown;
429
413
  try {
@@ -441,20 +425,65 @@ export function createApi(options: ApiOptions): Hono {
441
425
  switch (result.kind) {
442
426
  case 'ok':
443
427
  return c.json(result.data);
444
- case 'badJson':
445
- return c.json({ error: `Existing demo file is not valid JSON: ${result.detail}` }, 400);
446
- case 'badSchema':
447
- return c.json(
448
- { error: 'Existing demo file failed schema validation', issues: result.issues },
449
- 400,
450
- );
428
+ case 'alreadyExists':
429
+ return c.json({ error: `Project already exists at ${result.path}` }, 409);
451
430
  case 'scaffoldFailed':
452
431
  return c.json({ error: `Failed to scaffold project: ${result.message}` }, 500);
453
- case 'sdkWriteFailed':
454
- return c.json({ error: `Failed to write SDK helper: ${result.message}` }, 500);
455
432
  }
456
433
  });
457
434
 
435
+ // GET /api/schema — index of categories the skill / agents can introspect.
436
+ // Mirrors `seeflow schema` and the `seeflow_schema` MCP tool. Drill in via
437
+ // GET /api/schema/:name for the full JSON Schema(s) + invariant notes.
438
+ api.get('/schema', (c) => c.json({ ok: true as const, categories: listSchemaCategories() }));
439
+
440
+ api.get('/schema/:name', (c) => {
441
+ const name = c.req.param('name');
442
+ const payload = getSchemaCategory(name);
443
+ if (!payload) {
444
+ return c.json(
445
+ { error: `unknown schema category: ${name}`, available: schemaCategoryNames() },
446
+ 404,
447
+ );
448
+ }
449
+ return c.json({ ok: true as const, name, schemas: payload.schemas, notes: payload.notes });
450
+ });
451
+
452
+ // GET /api/ids/:type/:count — batch-mint canonical short ids. Mirrors
453
+ // `seeflow ids <type> <count>` and the `seeflow_ids` MCP tool. Pure compute,
454
+ // no state read, no studio side effects. Same alphabet, length, and
455
+ // rejection-sampling as every other id producer (operations.ts, canvas,
456
+ // upload regex), so generated ids match wherever they're inserted.
457
+ api.get('/ids/:type/:count', (c) => {
458
+ const type = c.req.param('type');
459
+ if (!isIdType(type)) {
460
+ return c.json(
461
+ {
462
+ ok: false as const,
463
+ error: `invalid type: ${type} (expected one of: ${ID_TYPES.join(', ')})`,
464
+ },
465
+ 400,
466
+ );
467
+ }
468
+ const rawCount = c.req.param('count');
469
+ const count = Number.parseInt(rawCount, 10);
470
+ if (
471
+ !Number.isFinite(count) ||
472
+ String(count) !== rawCount ||
473
+ count < 1 ||
474
+ count > MAX_ID_COUNT
475
+ ) {
476
+ return c.json(
477
+ {
478
+ ok: false as const,
479
+ error: `invalid count: ${rawCount} (expected an integer in [1, ${MAX_ID_COUNT}])`,
480
+ },
481
+ 400,
482
+ );
483
+ }
484
+ return c.json({ ok: true as const, ids: generateIds(type, count) });
485
+ });
486
+
458
487
  api.get('/flows', (c) => {
459
488
  const result = ops.listFlows();
460
489
  return c.json(result.data);
@@ -517,9 +546,9 @@ export function createApi(options: ApiOptions): Hono {
517
546
  });
518
547
 
519
548
  // GET /api/projects/:id/files/<path> — stream a project-scoped file from
520
- // <repoPath>/.seeflow/<path>. Path safety is layered: textual rejection
521
- // (absolute / traversal), then realpath check that the resolved file stays
522
- // inside the project's .seeflow root (defends against symlink escapes).
549
+ // <repoPath>/<path>. Path safety is layered: textual rejection (absolute /
550
+ // traversal), then realpath check that the resolved file stays inside the
551
+ // project root (defends against symlink escapes).
523
552
  api.get('/projects/:id/files/:path{.+}', async (c) => {
524
553
  const rawPath = c.req.param('path');
525
554
  let relPath: string;
@@ -643,11 +672,11 @@ export function createApi(options: ApiOptions): Hono {
643
672
  });
644
673
 
645
674
  // POST /api/projects/:id/nodes/:nodeId/files/upload — accept a multipart
646
- // image upload and persist it under `<project>/.seeflow/nodes/<nodeId>/`.
647
- // Multipart shape: `file` (Blob) and optional `filename` (the original OS
648
- // name). Allowlist + 5 MB cap guard against arbitrary uploads; the
649
- // destination folder is scoped to the node, so delete_node's removeNodeDir
650
- // cascade cleans up the asset along with the node row.
675
+ // image upload and persist it under `<project>/nodes/<nodeId>/`. Multipart
676
+ // shape: `file` (Blob) and optional `filename` (the original OS name).
677
+ // Allowlist + 5 MB cap guard against arbitrary uploads; the destination
678
+ // folder is scoped to the node, so delete_node's removeNodeDir cascade
679
+ // cleans up the asset along with the node row.
651
680
  api.post('/projects/:id/nodes/:nodeId/files/upload', async (c) => {
652
681
  const projectId = c.req.param('id');
653
682
  const nodeId = c.req.param('nodeId');
@@ -682,7 +711,7 @@ export function createApi(options: ApiOptions): Hono {
682
711
  return c.json({ error: 'invalid filename or extension' }, 400);
683
712
  }
684
713
 
685
- const nodeDir = join(entry.repoPath, '.seeflow', 'nodes', nodeId);
714
+ const nodeDir = join(entry.repoPath, 'nodes', nodeId);
686
715
  try {
687
716
  mkdirSync(nodeDir, { recursive: true });
688
717
  } catch (err) {
@@ -899,10 +928,9 @@ export function createApi(options: ApiOptions): Hono {
899
928
  return c.json({ ok: true, calledResetAction });
900
929
  });
901
930
 
902
- // PATCH a single node's position back into the on-disk flow.json. This is
903
- // the second (and only other) place the studio mutates user files — the
904
- // first being the SDK helper write in `register`. Atomic write via tempfile
905
- // + rename keeps editor diffs clean and avoids corruption mid-write.
931
+ // PATCH a single node's position back into the on-disk flow.json. Atomic
932
+ // write via tempfile + rename keeps editor diffs clean and avoids
933
+ // corruption mid-write.
906
934
  api.patch('/flows/:id/nodes/:nodeId/position', async (c) => {
907
935
  const id = c.req.param('id');
908
936
  const nodeId = c.req.param('nodeId');
@@ -62,8 +62,9 @@ export function printError(message: string, opts: CliOutcomeOptions = {}): never
62
62
  * - kind === 'badSchema'|'badJson' → exit 2
63
63
  * - kind === 'notFound'|'flowNotFound'|'fileNotFound'|
64
64
  * 'unknownNode'|'unknownConnector' → exit 3
65
- * - kind === 'duplicateIdInBatch'|'idAlreadyExists' → exit 4
66
- * - kind === 'writeFailed'|'sdkWriteFailed'|'scaffoldFailed' → exit 5
65
+ * - kind === 'duplicateIdInBatch'|'idAlreadyExists'|
66
+ * 'alreadyExists' → exit 4
67
+ * - kind === 'writeFailed'|'scaffoldFailed' → exit 5
67
68
  * - anything else → exit 1
68
69
  *
69
70
  * The error message mirrors the strings used by api.ts so the CLI's
@@ -121,10 +122,10 @@ function describeOutcome(outcome: { kind: string } & Record<string, unknown>): s
121
122
  : 'Id already exists';
122
123
  return `${prefix}: ${String(outcome.id ?? '')}`;
123
124
  }
125
+ case 'alreadyExists':
126
+ return `Project already exists at ${String(outcome.path ?? '')}`;
124
127
  case 'writeFailed':
125
128
  return `Failed to write demo file: ${String(outcome.message ?? '')}`;
126
- case 'sdkWriteFailed':
127
- return `Failed to write SDK helper: ${String(outcome.message ?? '')}`;
128
129
  case 'scaffoldFailed':
129
130
  return `Failed to scaffold project: ${String(outcome.message ?? '')}`;
130
131
  default:
@@ -142,8 +143,8 @@ export const EXIT_CODE_BY_KIND: Record<string, number> = {
142
143
  unknownConnector: 3,
143
144
  duplicateIdInBatch: 4,
144
145
  idAlreadyExists: 4,
146
+ alreadyExists: 4,
145
147
  writeFailed: 5,
146
- sdkWriteFailed: 5,
147
148
  scaffoldFailed: 5,
148
149
  };
149
150