@tuongaz/seeflow 0.1.41 → 0.1.47
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/README.md +2 -15
- package/dist/web/assets/{index-C029S3KL.js → index-BYeYJkCQ.js} +1541 -1541
- package/dist/web/assets/{index-BwdVgB2y.css → index-DSfixlbD.css} +1 -1
- package/dist/web/assets/{index.es-Ylk3HlXb.js → index.es-CqkMwhBu.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-Bf66gPs3.js → jspdf.es.min-DLHTB6Rk.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/flow.json +47 -47
- package/examples/ecommerce-platform/.seeflow/style.json +10 -10
- package/examples/order-pipeline/.seeflow/flow.json +17 -17
- package/examples/order-pipeline/.seeflow/style.json +4 -4
- package/package.json +2 -1
- package/src/api.ts +101 -14
- package/src/atomic-write.ts +16 -0
- package/src/cli-e2e.ts +424 -0
- package/src/cli-helpers.ts +65 -0
- package/src/cli.ts +371 -17
- package/src/file-ref.ts +27 -16
- package/src/mcp.ts +116 -23
- package/src/merge.ts +1 -1
- package/src/node-files.ts +48 -0
- package/src/operations.ts +325 -105
- package/src/proxy.ts +35 -6
- package/src/registry.ts +2 -1
- package/src/schema.ts +31 -25
- package/src/short-id.ts +24 -0
- package/src/status-runner.ts +9 -8
- package/src/watcher.ts +14 -14
- /package/examples/ecommerce-platform/.seeflow/{details/auth-service.md → nodes/node-3zFtHg6ENc/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/cart-service.md → nodes/node-5F424NWbEu/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/api-gateway.md → nodes/node-CbwYqb7NfB/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{scripts/platform-health.html → nodes/node-XwygzfKPZ5/view.html} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/notification-service.md → nodes/node-fkptXw7uvs/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/product-service.md → nodes/node-kwBY8YPmYM/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/payment-service.md → nodes/node-mPqan8rFYN/detail.md} +0 -0
- /package/examples/ecommerce-platform/.seeflow/{details/order-service.md → nodes/node-yKrg9DV5fJ/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/inventory-service.md → nodes/node-GXTKUcE3ye/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/post-orders.md → nodes/node-XKIyds0TDg/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/payment-service.md → nodes/node-YOYiHJpY0i/detail.md} +0 -0
- /package/examples/order-pipeline/.seeflow/{details/fulfillment-service.md → nodes/node-zUIH7WFnhK/detail.md} +0 -0
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
import { createEventBus } from './events.ts';
|
|
5
6
|
import { seeflowHome } from './paths.ts';
|
|
6
7
|
import { defaultProcessSpawner } from './process-spawner.ts';
|
|
@@ -40,6 +41,67 @@ const flagValue = (name: string): string | undefined => {
|
|
|
40
41
|
|
|
41
42
|
const hasFlag = (name: string): boolean => argv.includes(`--${name}`);
|
|
42
43
|
|
|
44
|
+
const requireArg = (idx: number, name: string): string => {
|
|
45
|
+
const v = argv[idx];
|
|
46
|
+
if (!v || v.startsWith('--')) {
|
|
47
|
+
printError(`Missing required positional argument: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
return v as string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
async function studioUrlOrDie(noStart: boolean): Promise<{ url: string; port: number }> {
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
const overrideUrl = process.env.SEEFLOW_STUDIO_URL?.replace(/\/+$/, '');
|
|
55
|
+
const url = overrideUrl ?? studioUrl(config);
|
|
56
|
+
await ensureStudioRunning(url, config.port, noStart);
|
|
57
|
+
return { url, port: config.port };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function bodyFromFlags(): Promise<unknown> {
|
|
61
|
+
return loadBody(
|
|
62
|
+
{ json: flagValue('json'), file: flagValue('file'), stdin: hasFlag('stdin') },
|
|
63
|
+
drainStdin,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function postJson(url: string, body: unknown): Promise<Response> {
|
|
68
|
+
return fetch(url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
async function handleResponse(res: Response): Promise<unknown> {
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
let parsed: unknown = text;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(text);
|
|
92
|
+
} catch {
|
|
93
|
+
/* keep raw text */
|
|
94
|
+
}
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const detail =
|
|
97
|
+
typeof parsed === 'object' && parsed !== null
|
|
98
|
+
? JSON.stringify(parsed)
|
|
99
|
+
: String(parsed).slice(0, 500);
|
|
100
|
+
printError(`Studio returned ${res.status}: ${detail}`);
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
const DEBUG = hasFlag('debug') || process.env.SEEFLOW_DEBUG === '1';
|
|
44
106
|
const dbg = (msg: string) => {
|
|
45
107
|
if (DEBUG) console.error(`[debug] ${msg}`);
|
|
@@ -58,9 +120,44 @@ if (argv.includes('--version') || argv.includes('-v')) {
|
|
|
58
120
|
await runStop();
|
|
59
121
|
} else if (sub === 'register') {
|
|
60
122
|
await runRegister();
|
|
61
|
-
} else if (
|
|
62
|
-
|
|
63
|
-
|
|
123
|
+
} else if (sub === 'flows:register') {
|
|
124
|
+
await runRegister();
|
|
125
|
+
} else if (sub === 'projects:create') {
|
|
126
|
+
await runProjectsCreate();
|
|
127
|
+
} else if (sub === 'flows:list') {
|
|
128
|
+
await runFlowsList();
|
|
129
|
+
} else if (sub === 'flows:get') {
|
|
130
|
+
await runFlowsGet();
|
|
131
|
+
} else if (sub === 'flows:delete') {
|
|
132
|
+
await runFlowsDelete();
|
|
133
|
+
} else if (sub === 'flows:layout') {
|
|
134
|
+
await runFlowsLayout();
|
|
135
|
+
} else if (sub === 'flows:play') {
|
|
136
|
+
await runFlowsPlay();
|
|
137
|
+
} else if (sub === 'nodes:add') {
|
|
138
|
+
await runNodesAdd();
|
|
139
|
+
} else if (sub === 'nodes:add-bulk') {
|
|
140
|
+
await runNodesAddBulk();
|
|
141
|
+
} else if (sub === 'nodes:patch') {
|
|
142
|
+
await runNodesPatch();
|
|
143
|
+
} else if (sub === 'nodes:move') {
|
|
144
|
+
await runNodesMove();
|
|
145
|
+
} else if (sub === 'nodes:reorder') {
|
|
146
|
+
await runNodesReorder();
|
|
147
|
+
} else if (sub === 'nodes:delete') {
|
|
148
|
+
await runNodesDelete();
|
|
149
|
+
} else if (sub === 'connectors:add') {
|
|
150
|
+
await runConnectorsAdd();
|
|
151
|
+
} else if (sub === 'connectors:add-bulk') {
|
|
152
|
+
await runConnectorsAddBulk();
|
|
153
|
+
} else if (sub === 'connectors:patch') {
|
|
154
|
+
await runConnectorsPatch();
|
|
155
|
+
} else if (sub === 'connectors:delete') {
|
|
156
|
+
await runConnectorsDelete();
|
|
157
|
+
} else if (sub === 'validate') {
|
|
158
|
+
await runValidate();
|
|
159
|
+
} else if (sub === 'e2e') {
|
|
160
|
+
await runE2e();
|
|
64
161
|
} else {
|
|
65
162
|
console.error(`Unknown subcommand: ${sub}`);
|
|
66
163
|
printHelp();
|
|
@@ -76,32 +173,58 @@ Usage:
|
|
|
76
173
|
npx -y @tuongaz/seeflow@latest [command] [options]
|
|
77
174
|
|
|
78
175
|
Commands:
|
|
79
|
-
start
|
|
80
|
-
stop
|
|
81
|
-
register
|
|
82
|
-
|
|
83
|
-
|
|
176
|
+
start Start the SeeFlow Studio server (default port 4321) — default when no command is given
|
|
177
|
+
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
|
|
180
|
+
projects:create Create a new project (--name <name>)
|
|
181
|
+
flows:list List registered flows
|
|
182
|
+
flows:get <id> Get flow details
|
|
183
|
+
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>
|
|
186
|
+
nodes:add <id> Add a node (--json/--file/--stdin)
|
|
187
|
+
nodes:add-bulk <id> Add many nodes (--json/--file/--stdin)
|
|
188
|
+
nodes:patch <id> <n> Patch a node (--json/--file/--stdin)
|
|
189
|
+
nodes:move <id> <n> Move a node (--x N --y N)
|
|
190
|
+
nodes:reorder <id> <n> Reorder a node (--op forward|backward|toFront|toBack|toIndex [--index N])
|
|
191
|
+
nodes:delete <id> <n> Delete a node
|
|
192
|
+
connectors:add <id> Add a connector (--json/--file/--stdin)
|
|
193
|
+
connectors:add-bulk <id> Add many connectors (--json/--file/--stdin)
|
|
194
|
+
connectors:patch <id> <connId> Patch a connector (--json/--file/--stdin)
|
|
195
|
+
connectors:delete <id> <connId> Delete a connector
|
|
196
|
+
validate Schema-validate a flow.json (--file <file> [--style <file>])
|
|
197
|
+
e2e <id> End-to-end validate a registered flow (--skip-nodes a,b)
|
|
198
|
+
version Print the CLI version
|
|
199
|
+
help Show this help message
|
|
84
200
|
|
|
85
201
|
Global options:
|
|
86
|
-
--version, -v
|
|
202
|
+
--version, -v Print the CLI version and exit
|
|
203
|
+
--no-start Fail if studio is not already running
|
|
204
|
+
|
|
205
|
+
Body source flags (where applicable):
|
|
206
|
+
--json '<JSON>' Inline JSON body
|
|
207
|
+
--file <path> Read JSON body from file
|
|
208
|
+
--stdin Read JSON body from stdin
|
|
87
209
|
|
|
88
210
|
Options (start):
|
|
89
|
-
--port <n>
|
|
90
|
-
--foreground
|
|
91
|
-
--daemon
|
|
92
|
-
--debug
|
|
211
|
+
--port <n> Listen on port n (default: 4321)
|
|
212
|
+
--foreground Run attached to the terminal (default: background)
|
|
213
|
+
--daemon Deprecated alias — background is already the default
|
|
214
|
+
--debug Verbose logs + pipe daemon output to ~/.seeflow/seeflow.log
|
|
93
215
|
|
|
94
216
|
Options (register):
|
|
95
|
-
--path <dir>
|
|
96
|
-
--flow <file>
|
|
97
|
-
|
|
98
|
-
--no-start Fail if studio is not already running
|
|
217
|
+
--path <dir> Path to repo root (default: current directory)
|
|
218
|
+
--flow <file> Path to flow JSON, relative to repo root
|
|
219
|
+
(default: .seeflow/flow.json)
|
|
99
220
|
|
|
100
221
|
Examples:
|
|
101
222
|
npx -y @tuongaz/seeflow@latest
|
|
102
223
|
npx -y @tuongaz/seeflow@latest --port 8080
|
|
103
224
|
npx -y @tuongaz/seeflow@latest start --foreground
|
|
104
225
|
npx -y @tuongaz/seeflow@latest register --path ./my-app
|
|
226
|
+
npx -y @tuongaz/seeflow@latest projects:create --name "Checkout"
|
|
227
|
+
npx -y @tuongaz/seeflow@latest flows:list
|
|
105
228
|
npx -y @tuongaz/seeflow@latest stop
|
|
106
229
|
`.trim(),
|
|
107
230
|
);
|
|
@@ -448,3 +571,234 @@ async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
|
|
|
448
571
|
}
|
|
449
572
|
return false;
|
|
450
573
|
}
|
|
574
|
+
|
|
575
|
+
// ---- HTTP-passthrough subcommands ----------------------------------------
|
|
576
|
+
|
|
577
|
+
async function runProjectsCreate() {
|
|
578
|
+
const name = flagValue('name');
|
|
579
|
+
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);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
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 });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function runFlowsGet() {
|
|
594
|
+
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);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function runFlowsDelete() {
|
|
602
|
+
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);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function runFlowsLayout() {
|
|
610
|
+
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);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function runFlowsPlay() {
|
|
619
|
+
const flowId = requireArg(1, '<flowId>');
|
|
620
|
+
const nodeId = requireArg(2, '<nodeId>');
|
|
621
|
+
const { url } = await studioUrlOrDie(hasFlag('no-start'));
|
|
622
|
+
const res = await postJson(
|
|
623
|
+
`${url}/api/flows/${encodeURIComponent(flowId)}/play/${encodeURIComponent(nodeId)}`,
|
|
624
|
+
{},
|
|
625
|
+
);
|
|
626
|
+
const out = (await handleResponse(res)) as object;
|
|
627
|
+
printOk(out);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function runNodesAdd() {
|
|
631
|
+
const flowId = requireArg(1, '<flowId>');
|
|
632
|
+
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);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function runNodesAddBulk() {
|
|
640
|
+
const flowId = requireArg(1, '<flowId>');
|
|
641
|
+
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);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function runNodesPatch() {
|
|
649
|
+
const flowId = requireArg(1, '<flowId>');
|
|
650
|
+
const nodeId = requireArg(2, '<nodeId>');
|
|
651
|
+
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);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function runNodesMove() {
|
|
662
|
+
const flowId = requireArg(1, '<flowId>');
|
|
663
|
+
const nodeId = requireArg(2, '<nodeId>');
|
|
664
|
+
const xRaw = flagValue('x');
|
|
665
|
+
const yRaw = flagValue('y');
|
|
666
|
+
if (xRaw === undefined || yRaw === undefined) {
|
|
667
|
+
printError('nodes:move requires --x <n> --y <n>');
|
|
668
|
+
}
|
|
669
|
+
const x = Number(xRaw);
|
|
670
|
+
const y = Number(yRaw);
|
|
671
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
672
|
+
printError('--x and --y must be finite numbers');
|
|
673
|
+
}
|
|
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);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function runNodesReorder() {
|
|
684
|
+
const flowId = requireArg(1, '<flowId>');
|
|
685
|
+
const nodeId = requireArg(2, '<nodeId>');
|
|
686
|
+
const op = flagValue('op');
|
|
687
|
+
if (!op) printError('nodes:reorder requires --op forward|backward|toFront|toBack|toIndex');
|
|
688
|
+
let body: Record<string, unknown> = { op };
|
|
689
|
+
if (op === 'toIndex') {
|
|
690
|
+
const idxRaw = flagValue('index');
|
|
691
|
+
if (idxRaw === undefined) printError('nodes:reorder --op toIndex requires --index <n>');
|
|
692
|
+
const index = Number(idxRaw);
|
|
693
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
694
|
+
printError('--index must be a non-negative integer');
|
|
695
|
+
}
|
|
696
|
+
body = { op, index };
|
|
697
|
+
}
|
|
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);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function runNodesDelete() {
|
|
708
|
+
const flowId = requireArg(1, '<flowId>');
|
|
709
|
+
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);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function runConnectorsAdd() {
|
|
719
|
+
const flowId = requireArg(1, '<flowId>');
|
|
720
|
+
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);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function runConnectorsAddBulk() {
|
|
728
|
+
const flowId = requireArg(1, '<flowId>');
|
|
729
|
+
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);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function runConnectorsPatch() {
|
|
740
|
+
const flowId = requireArg(1, '<flowId>');
|
|
741
|
+
const connId = requireArg(2, '<connectorId>');
|
|
742
|
+
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);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function runConnectorsDelete() {
|
|
753
|
+
const flowId = requireArg(1, '<flowId>');
|
|
754
|
+
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);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function runValidate() {
|
|
764
|
+
const file = flagValue('file');
|
|
765
|
+
const styleFile = flagValue('style');
|
|
766
|
+
if (!file) printError('Missing required flag: --file <flow.json>');
|
|
767
|
+
let flow: unknown;
|
|
768
|
+
try {
|
|
769
|
+
flow = JSON.parse(readFileSync(file as string, 'utf8'));
|
|
770
|
+
} catch (err) {
|
|
771
|
+
printError(`Failed to read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
772
|
+
}
|
|
773
|
+
let style: unknown;
|
|
774
|
+
if (styleFile) {
|
|
775
|
+
try {
|
|
776
|
+
style = JSON.parse(readFileSync(styleFile, 'utf8'));
|
|
777
|
+
} catch (err) {
|
|
778
|
+
printError(
|
|
779
|
+
`Failed to read ${styleFile}: ${err instanceof Error ? err.message : String(err)}`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
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[] };
|
|
786
|
+
if (body.ok === false) {
|
|
787
|
+
printError(`Schema validation failed: ${JSON.stringify(body.issues ?? [])}`);
|
|
788
|
+
}
|
|
789
|
+
printOk(body);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function runE2e() {
|
|
793
|
+
const flowId = requireArg(1, '<flowId>');
|
|
794
|
+
const skipNodesRaw = flagValue('skip-nodes');
|
|
795
|
+
const skipNodes = skipNodesRaw ? skipNodesRaw.split(',').filter(Boolean) : [];
|
|
796
|
+
const { url } = await studioUrlOrDie(hasFlag('no-start'));
|
|
797
|
+
const { validateEndToEnd } = await import('./cli-e2e.ts');
|
|
798
|
+
const report = await validateEndToEnd({ flowId, url, skipNodes });
|
|
799
|
+
if (!report.ok) {
|
|
800
|
+
process.stderr.write(`${JSON.stringify(report)}\n`);
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
803
|
+
printOk(report);
|
|
804
|
+
}
|
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
|
|
20
|
-
*
|
|
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
|
|
43
|
-
|
|
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(
|
|
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(
|
|
64
|
+
refs.add(seeflowRelPath);
|
|
58
65
|
return content;
|
|
59
66
|
} catch {
|
|
60
|
-
return missingMarker(
|
|
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(
|
|
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
|
}
|