@tuongaz/seeflow 0.1.42 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli-ops.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type Operations, createOperations } from './operations.ts';
2
+ import { createRegistry } from './registry.ts';
3
+
4
+ /**
5
+ * Build a single Operations handle for in-process CLI use.
6
+ *
7
+ * The CLI has no watcher and no statusRunner — play/reset are server-only
8
+ * features that still go via HTTP. When a CLI mutates a flow file, the
9
+ * running studio's flow watcher picks up the disk write externally and
10
+ * broadcasts flow:reload to connected SPA clients.
11
+ *
12
+ * `OperationsDeps` doesn't currently carry an EventBus, so we don't pass one;
13
+ * the *Impl functions only broadcast through `watcher.broadcastReload`, which
14
+ * is undefined in the CLI.
15
+ */
16
+ export function createCliOperations(): Operations {
17
+ return createOperations({ registry: createRegistry() });
18
+ }
package/src/cli.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import { drainStdin, loadBody, printError, printOk } from './cli-helpers.ts';
4
+ import { drainStdin, loadBody, printError, printOk, printOutcome } from './cli-helpers.ts';
5
+ import { COMMAND_MANIFEST, renderCommandHelp, renderCommandList } from './cli-manifest.ts';
6
+ import { createCliOperations } from './cli-ops.ts';
5
7
  import { createEventBus } from './events.ts';
8
+ import type { LayoutOptions } from './layout.ts';
9
+ import {
10
+ ConnectorPatchBodySchema,
11
+ ConnectorsBulkBodySchema,
12
+ NodePatchBodySchema,
13
+ NodesBulkBodySchema,
14
+ ReorderBodySchema,
15
+ } from './operations.ts';
6
16
  import { seeflowHome } from './paths.ts';
7
17
  import { defaultProcessSpawner } from './process-spawner.ts';
8
18
  import { type Registry, createRegistry } from './registry.ts';
@@ -72,18 +82,6 @@ async function postJson(url: string, body: unknown): Promise<Response> {
72
82
  });
73
83
  }
74
84
 
75
- async function patchJson(url: string, body: unknown): Promise<Response> {
76
- return fetch(url, {
77
- method: 'PATCH',
78
- headers: { 'content-type': 'application/json' },
79
- body: JSON.stringify(body),
80
- });
81
- }
82
-
83
- async function deleteRequest(url: string): Promise<Response> {
84
- return fetch(url, { method: 'DELETE' });
85
- }
86
-
87
85
  async function handleResponse(res: Response): Promise<unknown> {
88
86
  const text = await res.text();
89
87
  let parsed: unknown = text;
@@ -111,7 +109,7 @@ const daemonLogPath = () => join(seeflowHome(), 'seeflow.log');
111
109
  if (argv.includes('--version') || argv.includes('-v')) {
112
110
  await printVersion();
113
111
  } else if (sub === 'help' || sub === '--help' || sub === '-h') {
114
- printHelp();
112
+ await runHelp();
115
113
  } else if (sub === 'version') {
116
114
  await printVersion();
117
115
  } else if (!sub || sub === 'start' || sub.startsWith('-')) {
@@ -126,8 +124,12 @@ if (argv.includes('--version') || argv.includes('-v')) {
126
124
  await runProjectsCreate();
127
125
  } else if (sub === 'flows:list') {
128
126
  await runFlowsList();
127
+ } else if (sub === 'flows:summary') {
128
+ await runFlowsSummary();
129
129
  } else if (sub === 'flows:get') {
130
130
  await runFlowsGet();
131
+ } else if (sub === 'flows:graph') {
132
+ await runFlowsGraph();
131
133
  } else if (sub === 'flows:delete') {
132
134
  await runFlowsDelete();
133
135
  } else if (sub === 'flows:layout') {
@@ -138,6 +140,8 @@ if (argv.includes('--version') || argv.includes('-v')) {
138
140
  await runNodesAdd();
139
141
  } else if (sub === 'nodes:add-bulk') {
140
142
  await runNodesAddBulk();
143
+ } else if (sub === 'nodes:get') {
144
+ await runNodesGet();
141
145
  } else if (sub === 'nodes:patch') {
142
146
  await runNodesPatch();
143
147
  } else if (sub === 'nodes:move') {
@@ -172,19 +176,21 @@ seeflow — local studio for file-defined interactive demos
172
176
  Usage:
173
177
  npx -y @tuongaz/seeflow@latest [command] [options]
174
178
 
175
- Commands:
179
+ Commands (work without a running studio):
176
180
  start Start the SeeFlow Studio server (default port 4321) — default when no command is given
177
181
  stop Stop a background studio instance
178
- register Register a demo repo with the running studio (alias of flows:register)
179
- flows:register Register a demo repo with the running studio
182
+ register Register a demo repo, writing to ~/.seeflow/registry.json (alias of flows:register)
183
+ flows:register Register a demo repo
180
184
  projects:create Create a new project (--name <name>)
181
185
  flows:list List registered flows
186
+ flows:summary List registered flows (id + name + description only)
182
187
  flows:get <id> Get flow details
188
+ flows:graph <id> List nodes + connectors without inlined file content
183
189
  flows:delete <id> Unregister a flow
184
- flows:layout <id> POST a layout payload (--json/--file/--stdin)
185
- flows:play <id> <n> Trigger a play on node <n>
190
+ flows:layout <id> Apply ELK layout, writing style.json (--json/--file/--stdin optional)
186
191
  nodes:add <id> Add a node (--json/--file/--stdin)
187
192
  nodes:add-bulk <id> Add many nodes (--json/--file/--stdin)
193
+ nodes:get <id> <n> Get a node with detail / html content inlined
188
194
  nodes:patch <id> <n> Patch a node (--json/--file/--stdin)
189
195
  nodes:move <id> <n> Move a node (--x N --y N)
190
196
  nodes:reorder <id> <n> Reorder a node (--op forward|backward|toFront|toBack|toIndex [--index N])
@@ -194,13 +200,18 @@ Commands:
194
200
  connectors:patch <id> <connId> Patch a connector (--json/--file/--stdin)
195
201
  connectors:delete <id> <connId> Delete a connector
196
202
  validate Schema-validate a flow.json (--file <file> [--style <file>])
203
+
204
+ Commands (require a running studio):
205
+ flows:play <id> <n> Trigger a play on node <n>
197
206
  e2e <id> End-to-end validate a registered flow (--skip-nodes a,b)
207
+
208
+ Meta:
198
209
  version Print the CLI version
199
210
  help Show this help message
200
211
 
201
212
  Global options:
202
213
  --version, -v Print the CLI version and exit
203
- --no-start Fail if studio is not already running
214
+ --no-start For flows:play / e2e: fail instead of auto-starting the studio
204
215
 
205
216
  Body source flags (where applicable):
206
217
  --json '<JSON>' Inline JSON body
@@ -230,6 +241,19 @@ Examples:
230
241
  );
231
242
  }
232
243
 
244
+ async function runHelp() {
245
+ const target = argv[1] && !argv[1].startsWith('--') ? argv[1] : undefined;
246
+ if (target) {
247
+ try {
248
+ console.log(renderCommandHelp(target));
249
+ } catch (err) {
250
+ printError(err instanceof Error ? err.message : String(err));
251
+ }
252
+ return;
253
+ }
254
+ console.log(renderCommandList());
255
+ }
256
+
233
257
  async function runStart() {
234
258
  const config = readConfig();
235
259
  const portArg = flagValue('port');
@@ -458,10 +482,6 @@ function isEsrch(err: unknown): boolean {
458
482
  async function runRegister() {
459
483
  const repoPath = resolve(flagValue('path') ?? '.');
460
484
  const demoPathArg = flagValue('flow') ?? DEFAULT_FLOW_PATH;
461
- const noStart = hasFlag('no-start');
462
- const config = readConfig();
463
- const overrideUrl = process.env.SEEFLOW_STUDIO_URL?.replace(/\/+$/, '');
464
- const url = overrideUrl ?? studioUrl(config);
465
485
 
466
486
  const fullPath = isAbsolute(demoPathArg) ? demoPathArg : join(repoPath, demoPathArg);
467
487
  if (!existsSync(fullPath)) {
@@ -487,38 +507,28 @@ async function runRegister() {
487
507
  process.exit(1);
488
508
  }
489
509
 
490
- await ensureStudioRunning(url, config.port, noStart);
491
-
492
- let res: Response;
493
- try {
494
- res = await fetch(`${url}/api/flows/register`, {
495
- method: 'POST',
496
- headers: { 'content-type': 'application/json' },
497
- body: JSON.stringify({
498
- name: parsed.data.name,
499
- repoPath,
500
- flowPath: demoPathArg,
501
- }),
502
- });
503
- } catch (err) {
504
- console.error(`Could not reach studio at ${url}: ${String(err)}`);
505
- console.error('Start it first: seeflow start');
506
- process.exit(1);
510
+ const ops = createCliOperations();
511
+ const result = await ops.registerFlow({
512
+ name: parsed.data.name,
513
+ repoPath,
514
+ flowPath: demoPathArg,
515
+ });
516
+ if (result.kind !== 'ok') {
517
+ printOutcome(result);
507
518
  }
519
+ const body = result.data;
508
520
 
509
- if (!res.ok) {
510
- const text = await res.text();
511
- console.error(`Studio returned ${res.status}: ${text}`);
512
- process.exit(1);
521
+ // Show the studio URL only when a daemon is actually running.
522
+ const pid = readPid();
523
+ if (pid && isPidAlive(pid)) {
524
+ const config = readConfig();
525
+ const overrideUrl = process.env.SEEFLOW_STUDIO_URL?.replace(/\/+$/, '');
526
+ const url = overrideUrl ?? studioUrl(config);
527
+ console.log(`Registered "${parsed.data.name}" → ${url}/d/${body.slug}`);
528
+ } else {
529
+ console.log(`Registered "${parsed.data.name}" (slug: ${body.slug})`);
513
530
  }
514
531
 
515
- const body = (await res.json()) as {
516
- id: string;
517
- slug: string;
518
- sdk?: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
519
- };
520
- console.log(`Registered "${parsed.data.name}" → ${url}/d/${body.slug}`);
521
-
522
532
  if (body.sdk?.outcome === 'written') {
523
533
  console.log(`Wrote ${body.sdk.filePath} (event-bound state node detected)`);
524
534
  } else if (body.sdk?.outcome === 'present') {
@@ -577,42 +587,55 @@ async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
577
587
  async function runProjectsCreate() {
578
588
  const name = flagValue('name');
579
589
  if (!name) printError('Missing required flag: --name');
580
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
581
- const res = await postJson(`${url}/api/projects`, { name });
582
- const body = (await handleResponse(res)) as object;
583
- printOk(body);
590
+ const ops = createCliOperations();
591
+ const result = await ops.createProject({ name: name as string });
592
+ printOutcome(result);
584
593
  }
585
594
 
586
595
  async function runFlowsList() {
587
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
588
- const res = await fetch(`${url}/api/flows`);
589
- const body = (await handleResponse(res)) as unknown[];
590
- printOk({ flows: body });
596
+ const ops = createCliOperations();
597
+ const result = ops.listFlows();
598
+ printOk({ flows: result.data });
599
+ }
600
+
601
+ async function runFlowsSummary() {
602
+ const ops = createCliOperations();
603
+ const result = ops.listFlowsSummary();
604
+ printOk({ flows: result.data });
591
605
  }
592
606
 
593
607
  async function runFlowsGet() {
594
608
  const flowId = requireArg(1, '<flowId>');
595
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
596
- const res = await fetch(`${url}/api/flows/${encodeURIComponent(flowId)}`);
597
- const body = (await handleResponse(res)) as object;
598
- printOk(body);
609
+ const ops = createCliOperations();
610
+ const result = await ops.getFlow(flowId);
611
+ printOutcome(result);
612
+ }
613
+
614
+ async function runFlowsGraph() {
615
+ const flowId = requireArg(1, '<flowId>');
616
+ const ops = createCliOperations();
617
+ const result = await ops.getFlowGraph(flowId);
618
+ printOutcome(result);
599
619
  }
600
620
 
601
621
  async function runFlowsDelete() {
602
622
  const flowId = requireArg(1, '<flowId>');
603
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
604
- const res = await deleteRequest(`${url}/api/flows/${encodeURIComponent(flowId)}`);
605
- const body = (await handleResponse(res)) as object;
606
- printOk(body);
623
+ const ops = createCliOperations();
624
+ const result = ops.deleteFlow(flowId);
625
+ printOutcome(result);
607
626
  }
608
627
 
609
628
  async function runFlowsLayout() {
610
629
  const flowId = requireArg(1, '<flowId>');
611
- const body = await bodyFromFlags();
612
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
613
- const res = await postJson(`${url}/api/flows/${encodeURIComponent(flowId)}/layout`, body);
614
- const out = (await handleResponse(res)) as object;
615
- printOk(out);
630
+ // Body is optional — `{ options? }` shape if provided. Empty when omitted.
631
+ let options: LayoutOptions | undefined;
632
+ if (hasFlag('json') || hasFlag('file') || hasFlag('stdin')) {
633
+ const body = (await bodyFromFlags()) as { options?: LayoutOptions } | undefined;
634
+ options = body?.options;
635
+ }
636
+ const ops = createCliOperations();
637
+ const result = await ops.applyLayout(flowId, options);
638
+ printOutcome(result);
616
639
  }
617
640
 
618
641
  async function runFlowsPlay() {
@@ -630,32 +653,45 @@ async function runFlowsPlay() {
630
653
  async function runNodesAdd() {
631
654
  const flowId = requireArg(1, '<flowId>');
632
655
  const body = await bodyFromFlags();
633
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
634
- const res = await postJson(`${url}/api/flows/${encodeURIComponent(flowId)}/nodes`, body);
635
- const out = (await handleResponse(res)) as object;
636
- printOk(out);
656
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
657
+ printError('Body must be an object');
658
+ }
659
+ const ops = createCliOperations();
660
+ const result = await ops.addNode(flowId, body as Record<string, unknown>);
661
+ printOutcome(result);
637
662
  }
638
663
 
639
664
  async function runNodesAddBulk() {
640
665
  const flowId = requireArg(1, '<flowId>');
641
666
  const body = await bodyFromFlags();
642
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
643
- const res = await postJson(`${url}/api/flows/${encodeURIComponent(flowId)}/nodes/bulk`, body);
644
- const out = (await handleResponse(res)) as object;
645
- printOk(out);
667
+ const parsed = NodesBulkBodySchema.safeParse(body);
668
+ if (!parsed.success) {
669
+ printError(`Invalid nodes:add-bulk body: ${JSON.stringify(parsed.error.issues)}`);
670
+ }
671
+ const ops = createCliOperations();
672
+ const result = await ops.addNodesBulk(flowId, parsed.data);
673
+ printOutcome(result);
674
+ }
675
+
676
+ async function runNodesGet() {
677
+ const flowId = requireArg(1, '<flowId>');
678
+ const nodeId = requireArg(2, '<nodeId>');
679
+ const ops = createCliOperations();
680
+ const result = await ops.getNode(flowId, nodeId);
681
+ printOutcome(result);
646
682
  }
647
683
 
648
684
  async function runNodesPatch() {
649
685
  const flowId = requireArg(1, '<flowId>');
650
686
  const nodeId = requireArg(2, '<nodeId>');
651
687
  const body = await bodyFromFlags();
652
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
653
- const res = await patchJson(
654
- `${url}/api/flows/${encodeURIComponent(flowId)}/nodes/${encodeURIComponent(nodeId)}`,
655
- body,
656
- );
657
- const out = (await handleResponse(res)) as object;
658
- printOk(out);
688
+ const parsed = NodePatchBodySchema.safeParse(body);
689
+ if (!parsed.success) {
690
+ printError(`Invalid nodes:patch body: ${JSON.stringify(parsed.error.issues)}`);
691
+ }
692
+ const ops = createCliOperations();
693
+ const result = await ops.patchNode(flowId, nodeId, parsed.data);
694
+ printOutcome(result);
659
695
  }
660
696
 
661
697
  async function runNodesMove() {
@@ -671,13 +707,9 @@ async function runNodesMove() {
671
707
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
672
708
  printError('--x and --y must be finite numbers');
673
709
  }
674
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
675
- const res = await patchJson(
676
- `${url}/api/flows/${encodeURIComponent(flowId)}/nodes/${encodeURIComponent(nodeId)}/position`,
677
- { x, y },
678
- );
679
- const out = (await handleResponse(res)) as object;
680
- printOk(out);
710
+ const ops = createCliOperations();
711
+ const result = await ops.moveNode(flowId, nodeId, { x, y });
712
+ printOutcome(result);
681
713
  }
682
714
 
683
715
  async function runNodesReorder() {
@@ -685,7 +717,7 @@ async function runNodesReorder() {
685
717
  const nodeId = requireArg(2, '<nodeId>');
686
718
  const op = flagValue('op');
687
719
  if (!op) printError('nodes:reorder requires --op forward|backward|toFront|toBack|toIndex');
688
- let body: Record<string, unknown> = { op };
720
+ let raw: Record<string, unknown> = { op };
689
721
  if (op === 'toIndex') {
690
722
  const idxRaw = flagValue('index');
691
723
  if (idxRaw === undefined) printError('nodes:reorder --op toIndex requires --index <n>');
@@ -693,71 +725,67 @@ async function runNodesReorder() {
693
725
  if (!Number.isInteger(index) || index < 0) {
694
726
  printError('--index must be a non-negative integer');
695
727
  }
696
- body = { op, index };
728
+ raw = { op, index };
697
729
  }
698
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
699
- const res = await patchJson(
700
- `${url}/api/flows/${encodeURIComponent(flowId)}/nodes/${encodeURIComponent(nodeId)}/order`,
701
- body,
702
- );
703
- const out = (await handleResponse(res)) as object;
704
- printOk(out);
730
+ const parsed = ReorderBodySchema.safeParse(raw);
731
+ if (!parsed.success) {
732
+ printError(`Invalid nodes:reorder body: ${JSON.stringify(parsed.error.issues)}`);
733
+ }
734
+ const ops = createCliOperations();
735
+ const result = await ops.reorderNode(flowId, nodeId, parsed.data);
736
+ printOutcome(result);
705
737
  }
706
738
 
707
739
  async function runNodesDelete() {
708
740
  const flowId = requireArg(1, '<flowId>');
709
741
  const nodeId = requireArg(2, '<nodeId>');
710
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
711
- const res = await deleteRequest(
712
- `${url}/api/flows/${encodeURIComponent(flowId)}/nodes/${encodeURIComponent(nodeId)}`,
713
- );
714
- const out = (await handleResponse(res)) as object;
715
- printOk(out);
742
+ const ops = createCliOperations();
743
+ const result = await ops.deleteNode(flowId, nodeId);
744
+ printOutcome(result);
716
745
  }
717
746
 
718
747
  async function runConnectorsAdd() {
719
748
  const flowId = requireArg(1, '<flowId>');
720
749
  const body = await bodyFromFlags();
721
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
722
- const res = await postJson(`${url}/api/flows/${encodeURIComponent(flowId)}/connectors`, body);
723
- const out = (await handleResponse(res)) as object;
724
- printOk(out);
750
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
751
+ printError('Body must be an object');
752
+ }
753
+ const ops = createCliOperations();
754
+ const result = await ops.addConnector(flowId, body as Record<string, unknown>);
755
+ printOutcome(result);
725
756
  }
726
757
 
727
758
  async function runConnectorsAddBulk() {
728
759
  const flowId = requireArg(1, '<flowId>');
729
760
  const body = await bodyFromFlags();
730
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
731
- const res = await postJson(
732
- `${url}/api/flows/${encodeURIComponent(flowId)}/connectors/bulk`,
733
- body,
734
- );
735
- const out = (await handleResponse(res)) as object;
736
- printOk(out);
761
+ const parsed = ConnectorsBulkBodySchema.safeParse(body);
762
+ if (!parsed.success) {
763
+ printError(`Invalid connectors:add-bulk body: ${JSON.stringify(parsed.error.issues)}`);
764
+ }
765
+ const ops = createCliOperations();
766
+ const result = await ops.addConnectorsBulk(flowId, parsed.data);
767
+ printOutcome(result);
737
768
  }
738
769
 
739
770
  async function runConnectorsPatch() {
740
771
  const flowId = requireArg(1, '<flowId>');
741
772
  const connId = requireArg(2, '<connectorId>');
742
773
  const body = await bodyFromFlags();
743
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
744
- const res = await patchJson(
745
- `${url}/api/flows/${encodeURIComponent(flowId)}/connectors/${encodeURIComponent(connId)}`,
746
- body,
747
- );
748
- const out = (await handleResponse(res)) as object;
749
- printOk(out);
774
+ const parsed = ConnectorPatchBodySchema.safeParse(body);
775
+ if (!parsed.success) {
776
+ printError(`Invalid connectors:patch body: ${JSON.stringify(parsed.error.issues)}`);
777
+ }
778
+ const ops = createCliOperations();
779
+ const result = await ops.patchConnector(flowId, connId, parsed.data);
780
+ printOutcome(result);
750
781
  }
751
782
 
752
783
  async function runConnectorsDelete() {
753
784
  const flowId = requireArg(1, '<flowId>');
754
785
  const connId = requireArg(2, '<connectorId>');
755
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
756
- const res = await deleteRequest(
757
- `${url}/api/flows/${encodeURIComponent(flowId)}/connectors/${encodeURIComponent(connId)}`,
758
- );
759
- const out = (await handleResponse(res)) as object;
760
- printOk(out);
786
+ const ops = createCliOperations();
787
+ const result = await ops.deleteConnector(flowId, connId);
788
+ printOutcome(result);
761
789
  }
762
790
 
763
791
  async function runValidate() {
@@ -780,9 +808,8 @@ async function runValidate() {
780
808
  );
781
809
  }
782
810
  }
783
- const { url } = await studioUrlOrDie(hasFlag('no-start'));
784
- const res = await postJson(`${url}/api/validate`, { flow, style });
785
- const body = (await handleResponse(res)) as { ok?: boolean; issues?: unknown[] };
811
+ const ops = createCliOperations();
812
+ const body = ops.validate({ flow, style });
786
813
  if (body.ok === false) {
787
814
  printError(`Schema validation failed: ${JSON.stringify(body.issues ?? [])}`);
788
815
  }
package/src/events.ts CHANGED
@@ -11,7 +11,8 @@ export type StudioEventType =
11
11
  | 'node:done'
12
12
  | 'node:error'
13
13
  | 'node:status'
14
- | 'file:changed';
14
+ | 'file:changed'
15
+ | 'registry:reload';
15
16
 
16
17
  export interface StudioEvent {
17
18
  type: StudioEventType;
package/src/file-ref.ts CHANGED
@@ -14,13 +14,18 @@ const isCleanRelativePath = (p: string): boolean => {
14
14
  const invalidMarker = (rawPath: string) => `[seeflow: invalid file:// path '${rawPath}']`;
15
15
  const missingMarker = (rawPath: string) => `[seeflow: missing file '${rawPath}']`;
16
16
 
17
+ const looksLikeFlowNode = (obj: Record<string, unknown>): obj is { id: string; data: object } =>
18
+ typeof obj.id === 'string' && obj.data !== null && typeof obj.data === 'object';
19
+
17
20
  /**
18
21
  * Resolve every `file://<relative-path>` string in `raw` by reading the file
19
- * under `<seeflowRoot>` and substituting its UTF-8 content. Missing or invalid
20
- * paths are replaced with placeholder markers so schema parse still succeeds.
22
+ * under `<seeflowRoot>/nodes/<nodeId>/` (node-relative) and substituting its
23
+ * UTF-8 content. Strings outside any enclosing flow node are treated as
24
+ * invalid — every supported file:// ref currently lives inside `node.data`.
21
25
  *
22
- * Returns the mutated tree plus the sorted, de-duplicated list of relative
23
- * paths that resolved cleanly (the watcher tracks these for live reload).
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).
24
29
  */
25
30
  export function resolveFileRefs(
26
31
  raw: unknown,
@@ -34,46 +39,52 @@ export function resolveFileRefs(
34
39
  seeflowRealRoot = seeflowRoot;
35
40
  }
36
41
 
37
- const resolveString = (s: string): string => {
42
+ const resolveString = (s: string, nodeId: string | null): string => {
38
43
  if (!s.startsWith(FILE_PREFIX)) return s;
39
44
  const relPath = s.slice(FILE_PREFIX.length);
40
45
  if (!isCleanRelativePath(relPath)) return invalidMarker(relPath);
46
+ if (nodeId === null) return invalidMarker(relPath);
41
47
 
42
- const abs = join(seeflowRoot, relPath);
43
- if (!existsSync(abs)) return missingMarker(relPath);
48
+ const seeflowRelPath = `nodes/${nodeId}/${relPath}`;
49
+ const abs = join(seeflowRoot, seeflowRelPath);
50
+ if (!existsSync(abs)) return missingMarker(seeflowRelPath);
44
51
 
45
52
  // Symlink-escape defense: resolve realpath and confirm it stays inside root.
46
53
  let realAbs: string;
47
54
  try {
48
55
  realAbs = realpathSync(abs);
49
56
  } catch {
50
- return missingMarker(relPath);
57
+ return missingMarker(seeflowRelPath);
51
58
  }
52
59
  const rel = relative(seeflowRealRoot, realAbs);
53
60
  if (rel.startsWith('..') || isAbsolute(rel)) return invalidMarker(relPath);
54
61
 
55
62
  try {
56
63
  const content = readFileSync(realAbs, 'utf8');
57
- refs.add(relPath);
64
+ refs.add(seeflowRelPath);
58
65
  return content;
59
66
  } catch {
60
- return missingMarker(relPath);
67
+ return missingMarker(seeflowRelPath);
61
68
  }
62
69
  };
63
70
 
64
- const walk = (node: unknown): unknown => {
65
- if (typeof node === 'string') return resolveString(node);
66
- if (Array.isArray(node)) return node.map(walk);
71
+ const walk = (node: unknown, nodeId: string | null): unknown => {
72
+ if (typeof node === 'string') return resolveString(node, nodeId);
73
+ if (Array.isArray(node)) return node.map((v) => walk(v, nodeId));
67
74
  if (node && typeof node === 'object') {
75
+ const obj = node as Record<string, unknown>;
76
+ // Entering a flow node carves out a new resolution context for its subtree:
77
+ // any file:// inside `data` now resolves relative to nodes/<id>/.
78
+ const childNodeId = looksLikeFlowNode(obj) ? obj.id : nodeId;
68
79
  const out: Record<string, unknown> = {};
69
- for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
70
- out[k] = walk(v);
80
+ for (const [k, v] of Object.entries(obj)) {
81
+ out[k] = walk(v, childNodeId);
71
82
  }
72
83
  return out;
73
84
  }
74
85
  return node;
75
86
  };
76
87
 
77
- const resolved = walk(raw);
88
+ const resolved = walk(raw, null);
78
89
  return { resolved, refs: [...refs].sort() };
79
90
  }