@tuongaz/seeflow 0.1.3
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 +95 -0
- package/bin/seeflow +32 -0
- package/bin/seeflow-mcp +23 -0
- package/dist/web/assets/html2canvas.esm-CBrSDip1.js +22 -0
- package/dist/web/assets/index-BlhIMoXf.js +8005 -0
- package/dist/web/assets/index-CIpouxGY.css +1 -0
- package/dist/web/assets/index.es-D6Hswegt.js +18 -0
- package/dist/web/assets/purify.es-CLGrRn1w.js +3 -0
- package/dist/web/index.html +13 -0
- package/examples/ecommerce-platform/.seeflow/scripts/play.ts +2 -0
- package/examples/ecommerce-platform/.seeflow/seeflow.json +250 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +18 -0
- package/examples/order-pipeline/.seeflow/seeflow.json +86 -0
- package/examples/order-pipeline/README.md +11 -0
- package/examples/order-pipeline/package.json +6 -0
- package/package.json +55 -0
- package/public/runtime/tailwind.js +24394 -0
- package/src/api.ts +1093 -0
- package/src/cli.ts +329 -0
- package/src/demo.ts +65 -0
- package/src/diagram.ts +432 -0
- package/src/events.ts +70 -0
- package/src/mcp-shim.ts +93 -0
- package/src/mcp.ts +540 -0
- package/src/operations.ts +1192 -0
- package/src/process-spawner.ts +75 -0
- package/src/proxy.ts +393 -0
- package/src/registry.ts +139 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +441 -0
- package/src/sdk-template.ts +37 -0
- package/src/sdk-writer.ts +37 -0
- package/src/server.ts +211 -0
- package/src/shellout.ts +30 -0
- package/src/status-runner.ts +374 -0
- package/src/watcher.ts +383 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { cpSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { createEventBus } from './events.ts';
|
|
6
|
+
import { defaultProcessSpawner } from './process-spawner.ts';
|
|
7
|
+
import { type Registry, createRegistry } from './registry.ts';
|
|
8
|
+
import {
|
|
9
|
+
clearPid,
|
|
10
|
+
defaultPidPath,
|
|
11
|
+
isPidAlive,
|
|
12
|
+
readConfig,
|
|
13
|
+
readPid,
|
|
14
|
+
studioUrl,
|
|
15
|
+
writeConfig,
|
|
16
|
+
writePid,
|
|
17
|
+
} from './runtime.ts';
|
|
18
|
+
import { DemoSchema } from './schema.ts';
|
|
19
|
+
import { serve } from './server.ts';
|
|
20
|
+
import { createStatusRunner } from './status-runner.ts';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_DEMO_PATH = '.seeflow/seeflow.json';
|
|
23
|
+
const HEALTH_TIMEOUT_MS = 10_000;
|
|
24
|
+
const HEALTH_POLL_INTERVAL_MS = 150;
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const sub = argv[0];
|
|
28
|
+
|
|
29
|
+
const flagValue = (name: string): string | undefined => {
|
|
30
|
+
const flag = `--${name}`;
|
|
31
|
+
const eqArg = argv.find((a) => a.startsWith(`${flag}=`));
|
|
32
|
+
if (eqArg) return eqArg.slice(`${flag}=`.length);
|
|
33
|
+
const idx = argv.indexOf(flag);
|
|
34
|
+
if (idx >= 0 && idx + 1 < argv.length) return argv[idx + 1];
|
|
35
|
+
return undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const hasFlag = (name: string): boolean => argv.includes(`--${name}`);
|
|
39
|
+
|
|
40
|
+
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
41
|
+
printHelp();
|
|
42
|
+
} else if (sub === 'start') {
|
|
43
|
+
await runStart();
|
|
44
|
+
} else if (sub === 'stop') {
|
|
45
|
+
runStop();
|
|
46
|
+
} else if (sub === 'register') {
|
|
47
|
+
await runRegister();
|
|
48
|
+
} else if (['unregister', 'list'].includes(sub)) {
|
|
49
|
+
console.log(`seeflow ${sub}: not implemented (M1.B)`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
53
|
+
printHelp();
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printHelp() {
|
|
58
|
+
console.log(
|
|
59
|
+
`
|
|
60
|
+
seeflow — local studio for file-defined interactive demos
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
npx @tuongaz/seeflow <command> [options]
|
|
64
|
+
|
|
65
|
+
Commands:
|
|
66
|
+
start Start the SeeFlow Studio server (default port 4321)
|
|
67
|
+
stop Stop a background studio instance
|
|
68
|
+
register Register a demo repo with the running studio
|
|
69
|
+
help Show this help message
|
|
70
|
+
|
|
71
|
+
Options (start):
|
|
72
|
+
--port <n> Listen on port n (default: 4321)
|
|
73
|
+
--daemon Start in background and exit
|
|
74
|
+
|
|
75
|
+
Options (register):
|
|
76
|
+
--path <dir> Path to repo root (default: current directory)
|
|
77
|
+
--flow <file> Path to flow JSON, relative to repo root
|
|
78
|
+
(default: .seeflow/seeflow.json)
|
|
79
|
+
--no-start Fail if studio is not already running
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
npx @tuongaz/seeflow start
|
|
83
|
+
npx @tuongaz/seeflow start --port 8080 --daemon
|
|
84
|
+
npx @tuongaz/seeflow register --path ./my-app
|
|
85
|
+
npx @tuongaz/seeflow stop
|
|
86
|
+
`.trim(),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runStart() {
|
|
91
|
+
const config = readConfig();
|
|
92
|
+
const portArg = flagValue('port');
|
|
93
|
+
const port = portArg ? Number(portArg) : config.port;
|
|
94
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
95
|
+
console.error(`Invalid --port: ${portArg}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (hasFlag('daemon')) {
|
|
100
|
+
await spawnDaemon(port, config.host);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// persist the chosen address so other subcommands can find us
|
|
105
|
+
writeConfig({ port, host: config.host });
|
|
106
|
+
|
|
107
|
+
const registry = createRegistry();
|
|
108
|
+
const events = createEventBus();
|
|
109
|
+
const statusRunner = createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
|
|
110
|
+
const server = serve({ port, hostname: config.host, registry, events, statusRunner });
|
|
111
|
+
writePid(process.pid);
|
|
112
|
+
|
|
113
|
+
const cleanup = () => {
|
|
114
|
+
if (readPid() === process.pid) clearPid();
|
|
115
|
+
};
|
|
116
|
+
const shutdown = async () => {
|
|
117
|
+
cleanup();
|
|
118
|
+
try {
|
|
119
|
+
await statusRunner.stopAll();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.warn(
|
|
122
|
+
`[cli] statusRunner.stopAll() failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
process.exit(0);
|
|
126
|
+
};
|
|
127
|
+
process.on('SIGTERM', () => void shutdown());
|
|
128
|
+
process.on('SIGINT', () => void shutdown());
|
|
129
|
+
process.on('exit', cleanup);
|
|
130
|
+
|
|
131
|
+
console.log(`SeeFlow Studio listening on http://${server.hostname}:${server.port}`);
|
|
132
|
+
|
|
133
|
+
await seedExamples(registry);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function seedExamples(registry: Registry) {
|
|
137
|
+
await seedExample(registry, 'order-pipeline');
|
|
138
|
+
await seedExample(registry, 'ecommerce-platform');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function seedExample(registry: Registry, exampleName: string) {
|
|
142
|
+
const destDir = join(homedir(), '.seeflow', exampleName);
|
|
143
|
+
const demoPath = '.seeflow/seeflow.json';
|
|
144
|
+
|
|
145
|
+
// Always sync from source so that schema changes and example updates are
|
|
146
|
+
// reflected on every startup, even when the dest directory already exists.
|
|
147
|
+
const srcDir = join(import.meta.dir, `../examples/${exampleName}`);
|
|
148
|
+
if (!existsSync(srcDir)) return;
|
|
149
|
+
cpSync(srcDir, destDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
if (registry.getByRepoPathAndDemoPath(destDir, demoPath)) return;
|
|
152
|
+
|
|
153
|
+
const flowFile = join(destDir, demoPath);
|
|
154
|
+
if (!existsSync(flowFile)) return;
|
|
155
|
+
|
|
156
|
+
let demo: unknown;
|
|
157
|
+
try {
|
|
158
|
+
demo = await Bun.file(flowFile).json();
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parsed = DemoSchema.safeParse(demo);
|
|
164
|
+
if (!parsed.success) return;
|
|
165
|
+
|
|
166
|
+
registry.upsert({ name: parsed.data.name, repoPath: destDir, demoPath });
|
|
167
|
+
console.log(`Seeded example: ${parsed.data.name} → ${destDir}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function spawnDaemon(port: number, host: string) {
|
|
171
|
+
const url = `http://${host}:${port}`;
|
|
172
|
+
if (await healthOk(url)) {
|
|
173
|
+
console.log(`Studio already running at ${url}`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const proc = spawnDetachedStudio(port);
|
|
178
|
+
writePid(proc.pid);
|
|
179
|
+
writeConfig({ port, host });
|
|
180
|
+
|
|
181
|
+
if (!(await waitForHealth(url, HEALTH_TIMEOUT_MS))) {
|
|
182
|
+
console.error(`Timed out waiting for studio at ${url}/health`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
console.log(`SeeFlow Studio started in background on ${url} (pid ${proc.pid})`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function spawnDetachedStudio(port: number): { pid: number } {
|
|
189
|
+
const proc = Bun.spawn({
|
|
190
|
+
cmd: [process.execPath, import.meta.path, 'start', `--port=${port}`],
|
|
191
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
192
|
+
env: { ...process.env, SEEFLOW_DAEMON: '1' },
|
|
193
|
+
});
|
|
194
|
+
proc.unref();
|
|
195
|
+
return { pid: proc.pid };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runStop() {
|
|
199
|
+
const pid = readPid();
|
|
200
|
+
if (!pid) {
|
|
201
|
+
console.log(`No studio running (no pid file at ${defaultPidPath()}).`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!isPidAlive(pid)) {
|
|
205
|
+
console.log(`Stale pid file (pid ${pid} not running); cleaning up.`);
|
|
206
|
+
clearPid();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
process.kill(pid, 'SIGTERM');
|
|
211
|
+
console.log(`Sent SIGTERM to studio (pid ${pid}).`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`Failed to stop pid ${pid}: ${String(err)}`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function runRegister() {
|
|
219
|
+
const repoPath = resolve(flagValue('path') ?? '.');
|
|
220
|
+
const demoPathArg = flagValue('flow') ?? DEFAULT_DEMO_PATH;
|
|
221
|
+
const noStart = hasFlag('no-start');
|
|
222
|
+
const config = readConfig();
|
|
223
|
+
const overrideUrl = process.env.SEEFLOW_STUDIO_URL?.replace(/\/+$/, '');
|
|
224
|
+
const url = overrideUrl ?? studioUrl(config);
|
|
225
|
+
|
|
226
|
+
const fullPath = isAbsolute(demoPathArg) ? demoPathArg : join(repoPath, demoPathArg);
|
|
227
|
+
if (!existsSync(fullPath)) {
|
|
228
|
+
console.error(`No demo file at ${fullPath}`);
|
|
229
|
+
console.error(`Create ${DEFAULT_DEMO_PATH} in your repo, or pass --flow <path>.`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let demo: unknown;
|
|
234
|
+
try {
|
|
235
|
+
demo = await Bun.file(fullPath).json();
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error(`Failed to parse ${fullPath}: ${String(err)}`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parsed = DemoSchema.safeParse(demo);
|
|
242
|
+
if (!parsed.success) {
|
|
243
|
+
console.error(`${fullPath} failed schema validation:`);
|
|
244
|
+
for (const issue of parsed.error.issues) {
|
|
245
|
+
console.error(` ${issue.path.join('.') || '<root>'}: ${issue.message}`);
|
|
246
|
+
}
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await ensureStudioRunning(url, config.port, noStart);
|
|
251
|
+
|
|
252
|
+
let res: Response;
|
|
253
|
+
try {
|
|
254
|
+
res = await fetch(`${url}/api/demos/register`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'content-type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
name: parsed.data.name,
|
|
259
|
+
repoPath,
|
|
260
|
+
demoPath: demoPathArg,
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error(`Could not reach studio at ${url}: ${String(err)}`);
|
|
265
|
+
console.error('Start it first: seeflow start');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const text = await res.text();
|
|
271
|
+
console.error(`Studio returned ${res.status}: ${text}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const body = (await res.json()) as {
|
|
276
|
+
id: string;
|
|
277
|
+
slug: string;
|
|
278
|
+
sdk?: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
|
|
279
|
+
};
|
|
280
|
+
console.log(`Registered "${parsed.data.name}" → ${url}/d/${body.slug}`);
|
|
281
|
+
|
|
282
|
+
if (body.sdk?.outcome === 'written') {
|
|
283
|
+
console.log(`Wrote ${body.sdk.filePath} (event-bound state node detected)`);
|
|
284
|
+
} else if (body.sdk?.outcome === 'present') {
|
|
285
|
+
console.log(`SDK helper already present at ${body.sdk.filePath} (skipped)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function ensureStudioRunning(url: string, port: number, noStart: boolean) {
|
|
290
|
+
if (await healthOk(url)) return;
|
|
291
|
+
|
|
292
|
+
// Health failed — maybe the recorded pid is alive and still booting.
|
|
293
|
+
const pid = readPid();
|
|
294
|
+
if (pid && isPidAlive(pid) && (await waitForHealth(url, HEALTH_TIMEOUT_MS))) return;
|
|
295
|
+
|
|
296
|
+
if (noStart) {
|
|
297
|
+
console.error(`Studio is not running at ${url}.`);
|
|
298
|
+
console.error('Start it first: seeflow start');
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.log(`Studio not running at ${url}; starting in background...`);
|
|
303
|
+
const proc = spawnDetachedStudio(port);
|
|
304
|
+
writePid(proc.pid);
|
|
305
|
+
|
|
306
|
+
if (!(await waitForHealth(url, HEALTH_TIMEOUT_MS))) {
|
|
307
|
+
console.error(`Studio did not respond at ${url}/health within ${HEALTH_TIMEOUT_MS}ms`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
console.log(`Studio started (pid ${proc.pid}).`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function healthOk(url: string): Promise<boolean> {
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) });
|
|
316
|
+
return res.ok;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function waitForHealth(url: string, timeoutMs: number): Promise<boolean> {
|
|
323
|
+
const deadline = Date.now() + timeoutMs;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
if (await healthOk(url)) return true;
|
|
326
|
+
await Bun.sleep(HEALTH_POLL_INTERVAL_MS);
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
package/src/demo.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { EventBus } from './events.ts';
|
|
3
|
+
|
|
4
|
+
const STATUS_TO_EVENT = {
|
|
5
|
+
running: 'node:running',
|
|
6
|
+
done: 'node:done',
|
|
7
|
+
error: 'node:error',
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
const delay = (): Promise<void> => new Promise((r) => setTimeout(r, 200 + Math.random() * 200));
|
|
11
|
+
|
|
12
|
+
function nodeEmit(
|
|
13
|
+
events: EventBus,
|
|
14
|
+
demoId: string,
|
|
15
|
+
runId: string,
|
|
16
|
+
nodeId: string,
|
|
17
|
+
status: keyof typeof STATUS_TO_EVENT,
|
|
18
|
+
payload?: Record<string, unknown>,
|
|
19
|
+
) {
|
|
20
|
+
events.broadcast({
|
|
21
|
+
type: STATUS_TO_EVENT[status],
|
|
22
|
+
demoId,
|
|
23
|
+
payload: { nodeId, runId, ...payload },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runOrderPipeline(events: EventBus, demoId: string, runId: string, orderId: string) {
|
|
28
|
+
const emit = (
|
|
29
|
+
nodeId: string,
|
|
30
|
+
status: keyof typeof STATUS_TO_EVENT,
|
|
31
|
+
payload?: Record<string, unknown>,
|
|
32
|
+
) => nodeEmit(events, demoId, runId, nodeId, status, payload);
|
|
33
|
+
|
|
34
|
+
emit('post-orders', 'running');
|
|
35
|
+
await delay();
|
|
36
|
+
emit('post-orders', 'done', { orderId });
|
|
37
|
+
|
|
38
|
+
emit('inventory-service', 'running');
|
|
39
|
+
await delay();
|
|
40
|
+
emit('inventory-service', 'done', { reserved: true, warehouseId: 'wh_sydney' });
|
|
41
|
+
|
|
42
|
+
emit('payment-service', 'running');
|
|
43
|
+
await delay();
|
|
44
|
+
emit('payment-service', 'done', { chargeId: `ch_${Date.now()}`, amount: 4999 });
|
|
45
|
+
|
|
46
|
+
emit('fulfillment-service', 'running');
|
|
47
|
+
await delay();
|
|
48
|
+
emit('fulfillment-service', 'done', { shipmentId: `shp_${Date.now()}`, orderId });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createDemoRouter(events: EventBus): Hono {
|
|
52
|
+
const app = new Hono();
|
|
53
|
+
|
|
54
|
+
app.post('/orders', async (c) => {
|
|
55
|
+
const demoId = c.req.header('x-seeflow-demo-id') ?? '';
|
|
56
|
+
const runId = c.req.header('x-seeflow-run-id') ?? '';
|
|
57
|
+
const orderId = `ord_${Date.now()}`;
|
|
58
|
+
|
|
59
|
+
void runOrderPipeline(events, demoId, runId, orderId);
|
|
60
|
+
|
|
61
|
+
return c.json({ orderId });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return app;
|
|
65
|
+
}
|