@tuongaz/seeflow 0.1.61 → 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-BXYHeBKM.js → index-DAP_yx-l.js} +354 -354
  3. package/dist/web/assets/{index.es-BzG6d4Ro.js → index.es-2bA-nRVD.js} +1 -1
  4. package/dist/web/assets/{jspdf.es.min-CcOxqEhi.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 +65 -55
  11. package/src/cli-helpers.ts +6 -5
  12. package/src/cli-manifest.ts +103 -15
  13. package/src/cli.ts +85 -13
  14. package/src/diagram.ts +0 -1
  15. package/src/file-ref.ts +16 -15
  16. package/src/mcp.ts +58 -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 +3 -9
  23. package/src/schema.ts +36 -96
  24. package/src/server.ts +0 -4
  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.61",
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
@@ -40,6 +40,7 @@ import type { Registry } from './registry.ts';
40
40
  import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
41
41
  import { FlowSchema, ResolvedFlowSchema } from './schema.ts';
42
42
  import { type Spawner, defaultSpawner } from './shellout.ts';
43
+ import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
43
44
  import type { StatusRunner } from './status-runner.ts';
44
45
  import { readMergedFlow } from './watcher.ts';
45
46
  import type { FlowWatcher } from './watcher.ts';
@@ -77,14 +78,14 @@ const EMIT_STATUS_TO_EVENT = {
77
78
  const FilePathBodySchema = z.object({ path: z.string() });
78
79
 
79
80
  type ResolvedProjectFile =
80
- | { kind: 'ok'; absPath: string; seeflowRoot: string }
81
+ | { kind: 'ok'; absPath: string; projectRoot: string }
81
82
  | { kind: 'unknownProject' }
82
83
  | { kind: 'invalidPath'; reason: string }
83
84
  | { kind: 'fileMissing'; absPath: string };
84
85
 
85
86
  // Shared path-safety + filesystem resolution for project-scoped file routes.
86
87
  // Performs textual rejection of absolute paths / `..` traversal, then layered
87
- // realpath verification that the resolved file stays inside `<project>/.seeflow/`
88
+ // realpath verification that the resolved file stays inside the project root
88
89
  // (defense against symlink escapes). Returns the realpath of an existing file
89
90
  // on success, or `fileMissing` with the would-be absolute path so callers can
90
91
  // soft-fail with that path included for clipboard fallback.
@@ -99,15 +100,15 @@ function resolveProjectFile(
99
100
  const guard = validateRelativePath(relPath);
100
101
  if (guard.kind === 'invalid') return { kind: 'invalidPath', reason: guard.reason };
101
102
 
102
- const seeflowRoot = join(entry.repoPath, '.seeflow');
103
+ const projectRoot = entry.repoPath;
103
104
  let realRoot: string;
104
105
  try {
105
- realRoot = realpathSync(seeflowRoot);
106
+ realRoot = realpathSync(projectRoot);
106
107
  } catch {
107
- return { kind: 'fileMissing', absPath: resolve(seeflowRoot, relPath) };
108
+ return { kind: 'fileMissing', absPath: resolve(projectRoot, relPath) };
108
109
  }
109
110
 
110
- const target = resolve(seeflowRoot, relPath);
111
+ const target = resolve(projectRoot, relPath);
111
112
  let realTarget: string;
112
113
  try {
113
114
  realTarget = realpathSync(target);
@@ -120,7 +121,7 @@ function resolveProjectFile(
120
121
  return { kind: 'invalidPath', reason: 'path escapes project root' };
121
122
  }
122
123
 
123
- return { kind: 'ok', absPath: realTarget, seeflowRoot: realRoot };
124
+ return { kind: 'ok', absPath: realTarget, projectRoot: realRoot };
124
125
  }
125
126
 
126
127
  // Allowed extensions for /nodes/:nodeId/files/upload. Lowercased; matched after dropping the
@@ -175,8 +176,6 @@ export interface ApiOptions {
175
176
  * Tests use this to record call order across runPlay / runReset /
176
177
  * stopAllPlays and to drive each in isolation. */
177
178
  proxy?: ProxyFacade;
178
- /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
179
- projectBaseDir?: string;
180
179
  }
181
180
 
182
181
  /**
@@ -204,8 +203,7 @@ export function createApi(options: ApiOptions): Hono {
204
203
  const platform = options.platform ?? process.platform;
205
204
  const processSpawner = options.processSpawner;
206
205
  const proxy = options.proxy ?? defaultProxyFacade;
207
- const projectBaseDir = options.projectBaseDir;
208
- const ops = createOperations({ registry, watcher, projectBaseDir });
206
+ const ops = createOperations({ registry, watcher });
209
207
  const api = new Hono();
210
208
 
211
209
  api.post('/flows/register', async (c) => {
@@ -231,15 +229,6 @@ export function createApi(options: ApiOptions): Hono {
231
229
  return c.json({ error: 'Flow file is not valid JSON', detail: result.detail }, 400);
232
230
  case 'badSchema':
233
231
  return c.json({ error: 'Flow file failed schema validation', issues: result.issues }, 400);
234
- case 'sdkWriteFailed':
235
- return c.json(
236
- {
237
- error: `Failed to write SDK helper: ${result.message}`,
238
- id: result.id,
239
- slug: result.slug,
240
- },
241
- 500,
242
- );
243
232
  }
244
233
  });
245
234
 
@@ -299,8 +288,8 @@ export function createApi(options: ApiOptions): Hono {
299
288
  // POST /api/diagram/assemble — Phase 7a. The skill POSTs wiring + layout
300
289
  // and gets back the assembled demo (IDs normalized, dupes dropped, dangling
301
290
  // connectors removed, positions snapped to a 24px grid). Pure compute; the
302
- // skill writes the response to $TARGET/.seeflow/flow.json. No schema
303
- // 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.
304
293
  api.post('/diagram/assemble', async (c) => {
305
294
  let body: unknown;
306
295
  try {
@@ -414,17 +403,11 @@ export function createApi(options: ApiOptions): Hono {
414
403
  return c.json({ error: 'Body must be { flow, options? } or { nodes, edges, options? }' }, 400);
415
404
  });
416
405
 
417
- // POST /api/projects — UI-driven "Create new project" flow (US-020). Two
418
- // branches based on whether the target folder already has a SeeFlow
419
- // project set up at `<folderPath>/.seeflow/flow.json`:
420
- // 1. Existing setup: read + validate the on-disk demo and register it
421
- // as-is (no overwrite, no scaffolding). The user-supplied `name`
422
- // becomes the registry display name; the on-disk demo's `name` is
423
- // preserved on disk.
424
- // 2. Fresh scaffold: mkdir -p the folder + .seeflow/, write a default
425
- // scaffold flow.json keyed off `name`, and run the same SDK-emit
426
- // helper write the CLI register flow uses (a no-op for an empty
427
- // 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.
428
411
  api.post('/projects', async (c) => {
429
412
  let body: unknown;
430
413
  try {
@@ -442,17 +425,10 @@ export function createApi(options: ApiOptions): Hono {
442
425
  switch (result.kind) {
443
426
  case 'ok':
444
427
  return c.json(result.data);
445
- case 'badJson':
446
- return c.json({ error: `Existing demo file is not valid JSON: ${result.detail}` }, 400);
447
- case 'badSchema':
448
- return c.json(
449
- { error: 'Existing demo file failed schema validation', issues: result.issues },
450
- 400,
451
- );
428
+ case 'alreadyExists':
429
+ return c.json({ error: `Project already exists at ${result.path}` }, 409);
452
430
  case 'scaffoldFailed':
453
431
  return c.json({ error: `Failed to scaffold project: ${result.message}` }, 500);
454
- case 'sdkWriteFailed':
455
- return c.json({ error: `Failed to write SDK helper: ${result.message}` }, 500);
456
432
  }
457
433
  });
458
434
 
@@ -473,6 +449,41 @@ export function createApi(options: ApiOptions): Hono {
473
449
  return c.json({ ok: true as const, name, schemas: payload.schemas, notes: payload.notes });
474
450
  });
475
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
+
476
487
  api.get('/flows', (c) => {
477
488
  const result = ops.listFlows();
478
489
  return c.json(result.data);
@@ -535,9 +546,9 @@ export function createApi(options: ApiOptions): Hono {
535
546
  });
536
547
 
537
548
  // GET /api/projects/:id/files/<path> — stream a project-scoped file from
538
- // <repoPath>/.seeflow/<path>. Path safety is layered: textual rejection
539
- // (absolute / traversal), then realpath check that the resolved file stays
540
- // 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).
541
552
  api.get('/projects/:id/files/:path{.+}', async (c) => {
542
553
  const rawPath = c.req.param('path');
543
554
  let relPath: string;
@@ -661,11 +672,11 @@ export function createApi(options: ApiOptions): Hono {
661
672
  });
662
673
 
663
674
  // POST /api/projects/:id/nodes/:nodeId/files/upload — accept a multipart
664
- // image upload and persist it under `<project>/.seeflow/nodes/<nodeId>/`.
665
- // Multipart shape: `file` (Blob) and optional `filename` (the original OS
666
- // name). Allowlist + 5 MB cap guard against arbitrary uploads; the
667
- // destination folder is scoped to the node, so delete_node's removeNodeDir
668
- // 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.
669
680
  api.post('/projects/:id/nodes/:nodeId/files/upload', async (c) => {
670
681
  const projectId = c.req.param('id');
671
682
  const nodeId = c.req.param('nodeId');
@@ -700,7 +711,7 @@ export function createApi(options: ApiOptions): Hono {
700
711
  return c.json({ error: 'invalid filename or extension' }, 400);
701
712
  }
702
713
 
703
- const nodeDir = join(entry.repoPath, '.seeflow', 'nodes', nodeId);
714
+ const nodeDir = join(entry.repoPath, 'nodes', nodeId);
704
715
  try {
705
716
  mkdirSync(nodeDir, { recursive: true });
706
717
  } catch (err) {
@@ -917,10 +928,9 @@ export function createApi(options: ApiOptions): Hono {
917
928
  return c.json({ ok: true, calledResetAction });
918
929
  });
919
930
 
920
- // PATCH a single node's position back into the on-disk flow.json. This is
921
- // the second (and only other) place the studio mutates user files — the
922
- // first being the SDK helper write in `register`. Atomic write via tempfile
923
- // + 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.
924
934
  api.patch('/flows/:id/nodes/:nodeId/position', async (c) => {
925
935
  const id = c.req.param('id');
926
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