@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
@@ -134,7 +134,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
134
134
  synopsis: 'seeflow register [--path <dir>] [--flow <file>]',
135
135
  description:
136
136
  'Register a demo repo with the studio. Reads <repoPath>/<flow> (defaulting ' +
137
- 'to ./.seeflow/flow.json), validates the schema, and writes an entry to ' +
137
+ 'to ./flow.json), validates the schema, and writes an entry to ' +
138
138
  '~/.seeflow/registry.json. Alias of flows:register.',
139
139
  category: 'flows',
140
140
  args: [],
@@ -143,12 +143,12 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
143
143
  {
144
144
  name: 'flow',
145
145
  valuePlaceholder: '<file>',
146
- description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
146
+ description: 'Path to flow.json relative to repo root (default: flow.json)',
147
147
  },
148
148
  ],
149
149
  outputs: {
150
- okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
151
- errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
150
+ okExample: { id: 'abc12345', slug: 'checkout' },
151
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema'],
152
152
  },
153
153
  requiresStudio: false,
154
154
  examples: ['seeflow register', 'seeflow register --path ./my-app'],
@@ -164,13 +164,13 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
164
164
  {
165
165
  name: 'flow',
166
166
  valuePlaceholder: '<file>',
167
- description: 'Path to flow.json relative to repo root (default: .seeflow/flow.json)',
167
+ description: 'Path to flow.json relative to repo root (default: flow.json)',
168
168
  },
169
169
  ],
170
170
  body: { schemaRef: 'RegisterBody' },
171
171
  outputs: {
172
- okExample: { id: 'abc12345', slug: 'checkout', sdk: { outcome: 'skipped', filePath: null } },
173
- errorKinds: ['fileNotFound', 'badJson', 'badSchema', 'sdkWriteFailed'],
172
+ okExample: { id: 'abc12345', slug: 'checkout' },
173
+ errorKinds: ['fileNotFound', 'badJson', 'badSchema'],
174
174
  },
175
175
  requiresStudio: false,
176
176
  examples: ['seeflow flows:register --path ./my-app'],
@@ -303,22 +303,36 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
303
303
  // ---- project -----------------------------------------------------------
304
304
  {
305
305
  name: 'projects:create',
306
- synopsis: 'seeflow projects:create --name <name>',
306
+ synopsis: 'seeflow projects:create --path <dir> --name <name> [--description <text>]',
307
307
  description:
308
- 'Scaffold a new project under ~/.seeflow/<slug>/ with an empty flow.json and ' +
309
- 'register it. The flow id is returned for follow-up writes.',
308
+ 'Scaffold a new project at <path> with an empty flow.json and register it. ' +
309
+ 'Errors if <path>/flow.json already exists use flows:register for ' +
310
+ 'an existing project.',
310
311
  category: 'project',
311
312
  args: [],
312
313
  flags: [
314
+ {
315
+ name: 'path',
316
+ valuePlaceholder: '<dir>',
317
+ description: 'Project folder (created if it does not exist)',
318
+ required: true,
319
+ },
313
320
  { name: 'name', valuePlaceholder: '<name>', description: 'Project name', required: true },
321
+ {
322
+ name: 'description',
323
+ valuePlaceholder: '<text>',
324
+ description: 'Optional human description, written into flow.json',
325
+ },
314
326
  ],
315
327
  body: { schemaRef: 'CreateProjectBody' },
316
328
  outputs: {
317
- okExample: { id: 'abc12345', slug: 'checkout', scaffolded: true },
318
- errorKinds: ['scaffoldFailed'],
329
+ okExample: { id: 'abc12345', slug: 'checkout' },
330
+ errorKinds: ['alreadyExists', 'scaffoldFailed'],
319
331
  },
320
332
  requiresStudio: false,
321
- examples: ['seeflow projects:create --name "Checkout"'],
333
+ examples: [
334
+ 'seeflow projects:create --path ./checkout --name "Checkout" --description "Cart + payments flow"',
335
+ ],
322
336
  },
323
337
  // ---- nodes -------------------------------------------------------------
324
338
  {
@@ -331,7 +345,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
331
345
  body: {
332
346
  example: {
333
347
  type: 'stateNode',
334
- data: { name: 'hello', kind: 'state', stateSource: { kind: 'request' } },
348
+ data: { name: 'hello', stateSource: { kind: 'request' } },
335
349
  },
336
350
  },
337
351
  outputs: {
@@ -514,7 +528,38 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
514
528
  ],
515
529
  outputs: { okExample: { ok: true } },
516
530
  requiresStudio: false,
517
- examples: ['seeflow validate --file .seeflow/flow.json'],
531
+ examples: ['seeflow validate --file flow.json'],
532
+ },
533
+ {
534
+ name: 'ids',
535
+ synopsis: 'seeflow ids <type> <count>',
536
+ description:
537
+ 'Generate <count> canonical short ids of the given <type>, one per line ' +
538
+ 'on stdout. <type> must be `node` (emits `node-<10 base62 chars>`) or ' +
539
+ '`connector` (emits `conn-<10 base62 chars>`). <count> must be an ' +
540
+ 'integer in [1, 100]. Uses the same alphabet, length, and rejection-' +
541
+ 'sampling logic as the canvas / server / upload regex, so skill-minted ' +
542
+ 'ids match every other id producer in the studio. Pure compute — no ' +
543
+ 'studio required. Call once per type (i.e. one call for nodes, one for ' +
544
+ 'connectors) when seeding a flow.json.',
545
+ category: 'meta',
546
+ args: [
547
+ {
548
+ name: 'type',
549
+ required: true,
550
+ description: "Id kind to generate: 'node' (→ `node-…`) or 'connector' (→ `conn-…`)",
551
+ },
552
+ {
553
+ name: 'count',
554
+ required: true,
555
+ description: 'How many ids to print (integer, 1..100)',
556
+ },
557
+ ],
558
+ flags: [],
559
+ outputKind: 'text',
560
+ outputs: {},
561
+ requiresStudio: false,
562
+ examples: ['seeflow ids node 10', 'seeflow ids connector 5'],
518
563
  },
519
564
  {
520
565
  name: 'schema',
@@ -562,6 +607,49 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
562
607
  requiresStudio: true,
563
608
  examples: ['seeflow e2e abc12345'],
564
609
  },
610
+ {
611
+ name: 'emit',
612
+ synopsis:
613
+ 'seeflow emit <flowId> <nodeId> <status> [--run-id <id>] [--payload <json>] [--studio-url <url>]',
614
+ description:
615
+ 'Broadcast a node-status event to the studio (status: running | done | error). ' +
616
+ 'User apps shell out to this command instead of importing an in-repo helper; ' +
617
+ "the studio re-broadcasts the event on the flow's SSE stream.",
618
+ category: 'live',
619
+ args: [
620
+ { name: 'flowId', required: true, description: 'Flow id or slug' },
621
+ { name: 'nodeId', required: true, description: 'Node id in the flow' },
622
+ { name: 'status', required: true, description: 'Status: running | done | error' },
623
+ ],
624
+ flags: [
625
+ {
626
+ name: 'run-id',
627
+ valuePlaceholder: '<id>',
628
+ description: 'Correlate events emitted within a single run',
629
+ },
630
+ {
631
+ name: 'payload',
632
+ valuePlaceholder: '<json>',
633
+ description: 'JSON string merged into the event payload',
634
+ },
635
+ {
636
+ name: 'studio-url',
637
+ valuePlaceholder: '<url>',
638
+ description: 'Override studio base URL (skips auto-start; targets the URL as-is)',
639
+ },
640
+ { name: 'no-start', description: 'Fail if the studio is not already running' },
641
+ ],
642
+ outputs: {
643
+ okExample: { ok: true },
644
+ errorKinds: [],
645
+ },
646
+ requiresStudio: true,
647
+ examples: [
648
+ 'seeflow emit abc12345 api-charge done',
649
+ 'seeflow emit abc12345 api-charge error --payload \'{"code":402}\'',
650
+ 'seeflow emit abc12345 api-charge running --run-id run-9b3 --studio-url http://localhost:4321',
651
+ ],
652
+ },
565
653
  ];
566
654
 
567
655
  function resolveSchemaRef(ref: string): unknown {
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  NodePatchBodySchema,
13
13
  ReorderBodySchema,
14
14
  } from './operations.ts';
15
- import { seeflowHome } from './paths.ts';
15
+ import { PROJECT_FLOW_FILENAME, seeflowHome } from './paths.ts';
16
16
  import { defaultProcessSpawner } from './process-spawner.ts';
17
17
  import { type Registry, createRegistry } from './registry.ts';
18
18
  import {
@@ -28,9 +28,10 @@ import {
28
28
  } from './runtime.ts';
29
29
  import { FlowSchema } from './schema.ts';
30
30
  import { serve } from './server.ts';
31
+ import { MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
31
32
  import { createStatusRunner } from './status-runner.ts';
32
33
 
33
- const DEFAULT_FLOW_PATH = '.seeflow/flow.json';
34
+ const DEFAULT_FLOW_PATH = PROJECT_FLOW_FILENAME;
34
35
  const HEALTH_TIMEOUT_MS = 10_000;
35
36
  const HEALTH_POLL_INTERVAL_MS = 150;
36
37
  const STOP_TIMEOUT_MS = 5_000;
@@ -159,8 +160,12 @@ if (argv.includes('--version') || argv.includes('-v')) {
159
160
  await runValidate();
160
161
  } else if (sub === 'schema') {
161
162
  await runSchema();
163
+ } else if (sub === 'ids') {
164
+ await runIds();
162
165
  } else if (sub === 'e2e') {
163
166
  await runE2e();
167
+ } else if (sub === 'emit') {
168
+ await runEmit();
164
169
  } else {
165
170
  console.error(`Unknown subcommand: ${sub}`);
166
171
  printHelp();
@@ -180,7 +185,7 @@ Commands (work without a running studio):
180
185
  stop Stop a background studio instance
181
186
  register Register a demo repo, writing to ~/.seeflow/registry.json (alias of flows:register)
182
187
  flows:register Register a demo repo
183
- projects:create Create a new project (--name <name>)
188
+ projects:create Create a new project (--path <dir> --name <name> [--description <text>])
184
189
  flows:list List registered flows
185
190
  flows:summary List registered flows (id + name + description only)
186
191
  flows:get <id> Get flow details
@@ -200,9 +205,16 @@ Commands (work without a running studio):
200
205
  validate Schema-validate a flow.json (--file <file> [--style <file>])
201
206
  schema [<category>] Get the flow.json schema. No arg → category index;
202
207
  category arg → full JSON Schema(s) for that category
208
+ ids <type> <count> Print <count> short ids of the given <type>, one per
209
+ line. <type> is 'node' (-> 'node-...') or 'connector'
210
+ (-> 'conn-...'). <count> is 1..100. Call once per type
211
+ when seeding a flow.json (e.g. 'ids node 10', then
212
+ 'ids connector 12').
203
213
 
204
214
  Commands (require a running studio):
205
215
  flows:play <id> <n> Trigger a play on node <n>
216
+ emit <id> <n> <st> Broadcast a status event for node <n> (st: running|done|error)
217
+ [--run-id <id>] [--payload <json>] [--studio-url <url>]
206
218
  e2e <id> End-to-end validate a registered flow (--skip-nodes a,b)
207
219
 
208
220
  Meta:
@@ -227,14 +239,14 @@ Options (start):
227
239
  Options (register):
228
240
  --path <dir> Path to repo root (default: current directory)
229
241
  --flow <file> Path to flow JSON, relative to repo root
230
- (default: .seeflow/flow.json)
242
+ (default: flow.json)
231
243
 
232
244
  Examples:
233
245
  npx -y @tuongaz/seeflow@latest
234
246
  npx -y @tuongaz/seeflow@latest --port 8080
235
247
  npx -y @tuongaz/seeflow@latest start --foreground
236
248
  npx -y @tuongaz/seeflow@latest register --path ./my-app
237
- npx -y @tuongaz/seeflow@latest projects:create --name "Checkout"
249
+ npx -y @tuongaz/seeflow@latest projects:create --path ./checkout --name "Checkout"
238
250
  npx -y @tuongaz/seeflow@latest flows:list
239
251
  npx -y @tuongaz/seeflow@latest stop
240
252
  `.trim(),
@@ -321,7 +333,7 @@ async function seedExamples(registry: Registry) {
321
333
 
322
334
  async function seedExample(registry: Registry, exampleName: string) {
323
335
  const destDir = join(seeflowHome(), exampleName);
324
- const flowPath = '.seeflow/flow.json';
336
+ const flowPath = PROJECT_FLOW_FILENAME;
325
337
 
326
338
  // Always sync from source so that schema changes and example updates are
327
339
  // reflected on every startup, even when the dest directory already exists.
@@ -415,6 +427,25 @@ function reportDaemonFailure(logPath: string | undefined) {
415
427
  console.error(tail || '(log is empty — daemon exited before writing anything)');
416
428
  }
417
429
 
430
+ async function runIds() {
431
+ const typeArg = argv[1];
432
+ if (!typeArg || typeArg.startsWith('--')) {
433
+ printError("Missing required positional argument: type (expected 'node' or 'connector')");
434
+ }
435
+ if (!isIdType(typeArg)) {
436
+ printError(`Invalid type: ${typeArg} (expected 'node' or 'connector')`);
437
+ }
438
+ const rawCount = argv[2];
439
+ if (!rawCount || rawCount.startsWith('--')) {
440
+ printError(`Missing required positional argument: count (integer 1..${MAX_ID_COUNT})`);
441
+ }
442
+ const count = Number.parseInt(rawCount as string, 10);
443
+ if (!Number.isFinite(count) || count < 1 || count > MAX_ID_COUNT) {
444
+ printError(`Invalid count: ${rawCount} (expected an integer in [1, ${MAX_ID_COUNT}])`);
445
+ }
446
+ for (const id of generateIds(typeArg, count)) process.stdout.write(`${id}\n`);
447
+ }
448
+
418
449
  async function printVersion() {
419
450
  const pkgPath = join(import.meta.dir, '../package.json');
420
451
  const pkg = (await Bun.file(pkgPath).json()) as { version?: string };
@@ -529,12 +560,6 @@ async function runRegister() {
529
560
  } else {
530
561
  console.log(`Registered "${parsed.data.name}" (slug: ${body.slug})`);
531
562
  }
532
-
533
- if (body.sdk?.outcome === 'written') {
534
- console.log(`Wrote ${body.sdk.filePath} (event-bound state node detected)`);
535
- } else if (body.sdk?.outcome === 'present') {
536
- console.log(`SDK helper already present at ${body.sdk.filePath} (skipped)`);
537
- }
538
563
  }
539
564
 
540
565
  async function ensureStudioRunning(url: string, port: number, noStart: boolean) {
@@ -586,10 +611,18 @@ async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
586
611
  // ---- HTTP-passthrough subcommands ----------------------------------------
587
612
 
588
613
  async function runProjectsCreate() {
614
+ const rawPath = flagValue('path');
615
+ if (!rawPath) printError('Missing required flag: --path');
589
616
  const name = flagValue('name');
590
617
  if (!name) printError('Missing required flag: --name');
618
+ const description = flagValue('description');
619
+
591
620
  const ops = createCliOperations();
592
- const result = await ops.createProject({ name: name as string });
621
+ const result = await ops.createProject({
622
+ path: resolve(rawPath as string),
623
+ name: name as string,
624
+ ...(description !== undefined ? { description } : {}),
625
+ });
593
626
  printOutcome(result);
594
627
  }
595
628
 
@@ -651,6 +684,45 @@ async function runFlowsPlay() {
651
684
  printOk(out);
652
685
  }
653
686
 
687
+ async function runEmit() {
688
+ const flowId = requireArg(1, '<flowId>');
689
+ const nodeId = requireArg(2, '<nodeId>');
690
+ const status = requireArg(3, '<status>');
691
+ if (status !== 'running' && status !== 'done' && status !== 'error') {
692
+ printError(`Invalid status: ${status} (expected running | done | error)`);
693
+ }
694
+
695
+ const runId = flagValue('run-id');
696
+ const rawPayload = flagValue('payload');
697
+ let payload: unknown;
698
+ if (rawPayload !== undefined) {
699
+ try {
700
+ payload = JSON.parse(rawPayload);
701
+ } catch (err) {
702
+ const message = err instanceof Error ? err.message : String(err);
703
+ printError(`--payload must be valid JSON: ${message}`);
704
+ }
705
+ }
706
+
707
+ // Explicit --studio-url targets a specific instance; skip auto-start since
708
+ // the caller is asserting where the studio lives.
709
+ const studioUrlFlag = flagValue('studio-url');
710
+ let url: string;
711
+ if (studioUrlFlag) {
712
+ url = studioUrlFlag.replace(/\/+$/, '');
713
+ } else {
714
+ ({ url } = await studioUrlOrDie(hasFlag('no-start')));
715
+ }
716
+
717
+ const body: Record<string, unknown> = { flowId, nodeId, status };
718
+ if (runId !== undefined) body.runId = runId;
719
+ if (payload !== undefined) body.payload = payload;
720
+
721
+ const res = await postJson(`${url}/api/emit`, body);
722
+ const out = (await handleResponse(res)) as object;
723
+ printOk(out);
724
+ }
725
+
654
726
  async function runNodesAdd() {
655
727
  const flowId = requireArg(1, '<flowId>');
656
728
  const body = await bodyFromFlags();
package/src/diagram.ts CHANGED
@@ -225,7 +225,6 @@ const normalizeConnectors = (
225
225
  const key = [
226
226
  source,
227
227
  target,
228
- String(raw.kind ?? ''),
229
228
  String(raw.sourceHandle ?? ''),
230
229
  String(raw.targetHandle ?? ''),
231
230
  ].join('\t');
package/src/file-ref.ts CHANGED
@@ -19,24 +19,25 @@ const looksLikeFlowNode = (obj: Record<string, unknown>): obj is { id: string; d
19
19
 
20
20
  /**
21
21
  * Resolve every `file://<relative-path>` string in `raw` by reading the file
22
- * under `<seeflowRoot>/nodes/<nodeId>/` (node-relative) and substituting its
22
+ * under `<projectRoot>/nodes/<nodeId>/` (node-relative) and substituting its
23
23
  * UTF-8 content. Strings outside any enclosing flow node are treated as
24
24
  * invalid — every supported file:// ref currently lives inside `node.data`.
25
25
  *
26
- * Returns the mutated tree plus the sorted, de-duplicated list of seeflow-root-relative
27
- * paths that resolved cleanly (the watcher tracks these for live reload, so the
28
- * external contract uses `nodes/<id>/<file>` even though the source string is short).
26
+ * Returns the mutated tree plus the sorted, de-duplicated list of project-
27
+ * root-relative paths that resolved cleanly (the watcher tracks these for
28
+ * live reload, so the external contract uses `nodes/<id>/<file>` even though
29
+ * the source string is short).
29
30
  */
30
31
  export function resolveFileRefs(
31
32
  raw: unknown,
32
- seeflowRoot: string,
33
+ projectRoot: string,
33
34
  ): { resolved: unknown; refs: string[] } {
34
35
  const refs = new Set<string>();
35
- let seeflowRealRoot: string;
36
+ let projectRealRoot: string;
36
37
  try {
37
- seeflowRealRoot = existsSync(seeflowRoot) ? realpathSync(seeflowRoot) : seeflowRoot;
38
+ projectRealRoot = existsSync(projectRoot) ? realpathSync(projectRoot) : projectRoot;
38
39
  } catch {
39
- seeflowRealRoot = seeflowRoot;
40
+ projectRealRoot = projectRoot;
40
41
  }
41
42
 
42
43
  const resolveString = (s: string, nodeId: string | null): string => {
@@ -45,26 +46,26 @@ export function resolveFileRefs(
45
46
  if (!isCleanRelativePath(relPath)) return invalidMarker(relPath);
46
47
  if (nodeId === null) return invalidMarker(relPath);
47
48
 
48
- const seeflowRelPath = `nodes/${nodeId}/${relPath}`;
49
- const abs = join(seeflowRoot, seeflowRelPath);
50
- if (!existsSync(abs)) return missingMarker(seeflowRelPath);
49
+ const projectRelPath = `nodes/${nodeId}/${relPath}`;
50
+ const abs = join(projectRoot, projectRelPath);
51
+ if (!existsSync(abs)) return missingMarker(projectRelPath);
51
52
 
52
53
  // Symlink-escape defense: resolve realpath and confirm it stays inside root.
53
54
  let realAbs: string;
54
55
  try {
55
56
  realAbs = realpathSync(abs);
56
57
  } catch {
57
- return missingMarker(seeflowRelPath);
58
+ return missingMarker(projectRelPath);
58
59
  }
59
- const rel = relative(seeflowRealRoot, realAbs);
60
+ const rel = relative(projectRealRoot, realAbs);
60
61
  if (rel.startsWith('..') || isAbsolute(rel)) return invalidMarker(relPath);
61
62
 
62
63
  try {
63
64
  const content = readFileSync(realAbs, 'utf8');
64
- refs.add(seeflowRelPath);
65
+ refs.add(projectRelPath);
65
66
  return content;
66
67
  } catch {
67
- return missingMarker(seeflowRelPath);
68
+ return missingMarker(projectRelPath);
68
69
  }
69
70
  };
70
71
 
package/src/mcp.ts CHANGED
@@ -21,13 +21,12 @@ import {
21
21
  } from './operations.ts';
22
22
  import type { Registry } from './registry.ts';
23
23
  import { getSchemaCategory, listSchemaCategories, schemaCategoryNames } from './schema-catalog.ts';
24
+ import { ID_TYPES, MAX_ID_COUNT, generateIds, isIdType } from './short-id.ts';
24
25
  import type { FlowWatcher } from './watcher.ts';
25
26
 
26
27
  export interface CreateMcpServerOptions {
27
28
  registry: Registry;
28
29
  watcher?: FlowWatcher;
29
- /** Override base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
30
- projectBaseDir?: string;
31
30
  }
32
31
 
33
32
  // Tools are pushed into this in-memory list inside `createMcpServer`. Each
@@ -231,6 +230,57 @@ const buildTools = (ops: Operations): McpTool[] => [
231
230
  return okResult({ name, schemas: payload.schemas, notes: payload.notes });
232
231
  },
233
232
  },
233
+ {
234
+ name: 'seeflow_ids',
235
+ description:
236
+ 'Batch-mint canonical short ids. `type` is `node` (emits `node-<10 base62 chars>`) ' +
237
+ 'or `connector` (emits `conn-<10 base62 chars>`); `count` is an integer in ' +
238
+ '[1, 100]. Pure compute — no flow side effects, no studio state read. Use ' +
239
+ 'before authoring a flow.json so minted ids match every other id producer ' +
240
+ 'in the studio (canvas, server, upload regex). Call once per type when ' +
241
+ 'seeding a flow (one call for nodes, one for connectors).',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ type: {
246
+ type: 'string',
247
+ enum: [...ID_TYPES],
248
+ description: "Id kind: 'node' (→ `node-…`) or 'connector' (→ `conn-…`)",
249
+ },
250
+ count: {
251
+ type: 'integer',
252
+ minimum: 1,
253
+ maximum: MAX_ID_COUNT,
254
+ description: `How many ids to mint (1..${MAX_ID_COUNT})`,
255
+ },
256
+ },
257
+ required: ['type', 'count'],
258
+ additionalProperties: false,
259
+ },
260
+ handler: async (args) => {
261
+ const body =
262
+ args && typeof args === 'object' && !Array.isArray(args)
263
+ ? (args as { type?: unknown; count?: unknown })
264
+ : {};
265
+ const { type, count } = body;
266
+ if (!isIdType(type)) {
267
+ return errorResult(
268
+ `invalid type: ${String(type)} (expected one of: ${ID_TYPES.join(', ')})`,
269
+ );
270
+ }
271
+ if (
272
+ typeof count !== 'number' ||
273
+ !Number.isInteger(count) ||
274
+ count < 1 ||
275
+ count > MAX_ID_COUNT
276
+ ) {
277
+ return errorResult(
278
+ `invalid count: ${String(count)} (expected an integer in [1, ${MAX_ID_COUNT}])`,
279
+ );
280
+ }
281
+ return okResult({ ids: generateIds(type, count) });
282
+ },
283
+ },
234
284
  {
235
285
  name: 'validate_seeflow',
236
286
  description:
@@ -368,8 +418,6 @@ const buildTools = (ops: Operations): McpTool[] => [
368
418
  return errorResult(
369
419
  `Flow file failed schema validation: ${JSON.stringify(result.issues)}`,
370
420
  );
371
- case 'sdkWriteFailed':
372
- return errorResult(`Failed to write SDK helper: ${result.message}`);
373
421
  }
374
422
  },
375
423
  },
@@ -391,7 +439,8 @@ const buildTools = (ops: Operations): McpTool[] => [
391
439
  },
392
440
  {
393
441
  name: 'seeflow_create_project',
394
- description: 'Create a new SeeFlow project folder, or register an existing one.',
442
+ description:
443
+ 'Scaffold a new SeeFlow project at the given path. Errors if a project already exists there.',
395
444
  inputSchema: inputSchemaFromZod(CreateProjectBodySchema),
396
445
  handler: async (args) => {
397
446
  const parsed = CreateProjectBodySchema.safeParse(args);
@@ -402,23 +451,17 @@ const buildTools = (ops: Operations): McpTool[] => [
402
451
  switch (result.kind) {
403
452
  case 'ok':
404
453
  return okResult(result.data);
405
- case 'badJson':
406
- return errorResult(`Existing demo file is not valid JSON: ${result.detail}`);
407
- case 'badSchema':
408
- return errorResult(
409
- `Existing demo file failed schema validation: ${JSON.stringify(result.issues)}`,
410
- );
454
+ case 'alreadyExists':
455
+ return errorResult(`Project already exists at ${result.path}`);
411
456
  case 'scaffoldFailed':
412
457
  return errorResult(`Failed to scaffold project: ${result.message}`);
413
- case 'sdkWriteFailed':
414
- return errorResult(`Failed to write SDK helper: ${result.message}`);
415
458
  }
416
459
  },
417
460
  },
418
461
  {
419
462
  name: 'seeflow_add_node',
420
463
  description:
421
- 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/.seeflow/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
464
+ 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
422
465
  inputSchema: inputSchemaFromZod(AddNodeInputSchema),
423
466
  handler: async (args) => {
424
467
  const parsed = AddNodeInputSchema.safeParse(args);
@@ -542,7 +585,7 @@ const buildTools = (ops: Operations): McpTool[] => [
542
585
  {
543
586
  name: 'seeflow_patch_node',
544
587
  description:
545
- 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/.seeflow/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
588
+ 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
546
589
  inputSchema: inputSchemaFromZod(PatchNodeInputSchema),
547
590
  handler: async (args) => {
548
591
  const parsed = PatchNodeInputSchema.safeParse(args);
@@ -710,7 +753,6 @@ export function createMcpServer(options: CreateMcpServerOptions): Server {
710
753
  const ops = createOperations({
711
754
  registry: options.registry,
712
755
  watcher: options.watcher,
713
- projectBaseDir: options.projectBaseDir,
714
756
  });
715
757
  const tools = buildTools(ops);
716
758
 
package/src/merge.ts CHANGED
@@ -76,7 +76,6 @@ const CONNECTOR_FLOW_KEYS = new Set([
76
76
  'id',
77
77
  'source',
78
78
  'target',
79
- 'kind',
80
79
  'label',
81
80
  'method',
82
81
  'url',
package/src/node-files.ts CHANGED
@@ -3,9 +3,9 @@ import { dirname, join } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.ts';
4
4
 
5
5
  // Spec for fields that the studio externalizes to disk under
6
- // `<project>/.seeflow/nodes/<id>/<fileName>`. `nodeTypes` (when present)
7
- // scopes the spec entry to specific node types; absent means "applies to
8
- // every node type". Adding a future text field is one line.
6
+ // `<project>/nodes/<id>/<fileName>`. `nodeTypes` (when present) scopes the
7
+ // spec entry to specific node types; absent means "applies to every node
8
+ // type". Adding a future text field is one line.
9
9
  export interface ExternalizedFieldSpec {
10
10
  field: string;
11
11
  fileName: string;
@@ -36,7 +36,7 @@ export const nodeFileRelPath = (nodeId: string, fileName: string): string =>
36
36
  export const nodeFileRef = (_nodeId: string, fileName: string): string => `file://${fileName}`;
37
37
 
38
38
  export const nodeFileAbsPath = (repoPath: string, nodeId: string, fileName: string): string =>
39
- join(repoPath, '.seeflow', nodeFileRelPath(nodeId, fileName));
39
+ join(repoPath, nodeFileRelPath(nodeId, fileName));
40
40
 
41
41
  export function writeNodeFile(absPath: string, content: string): void {
42
42
  mkdirSync(dirname(absPath), { recursive: true });
@@ -44,5 +44,5 @@ export function writeNodeFile(absPath: string, content: string): void {
44
44
  }
45
45
 
46
46
  export function removeNodeDir(repoPath: string, nodeId: string): void {
47
- rmSync(join(repoPath, '.seeflow', 'nodes', nodeId), { recursive: true, force: true });
47
+ rmSync(join(repoPath, 'nodes', nodeId), { recursive: true, force: true });
48
48
  }