@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/src/schema.ts ADDED
@@ -0,0 +1,441 @@
1
+ import { z } from 'zod';
2
+
3
+ const PositionSchema = z.object({
4
+ x: z.number(),
5
+ y: z.number(),
6
+ });
7
+
8
+ // US-008: HttpAction was the original shape for both playAction and resetAction
9
+ // in pre-script-action releases. After US-001 cut playAction to script-only and
10
+ // US-008 cut resetAction the same way, no schema in this file uses HttpAction
11
+ // anymore. Connectors keep the `http` *kind literal* for visual semantics, but
12
+ // that's an independent enum — search before re-adding any HttpActionSchema.
13
+ const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
14
+
15
+ // Curated palette tokens. Stored on disk as readable names; the frontend maps
16
+ // them to actual CSS values (theme-aware, light + dark).
17
+ export const ColorTokenSchema = z.enum([
18
+ 'default',
19
+ 'slate',
20
+ 'blue',
21
+ 'green',
22
+ 'amber',
23
+ 'red',
24
+ 'purple',
25
+ 'pink',
26
+ ]);
27
+
28
+ // Visual fields shared by every node type (functional + decorative). All
29
+ // optional — existing demo files predate them and must continue to parse.
30
+ // US-019: `locked` freezes a node in place (no drag / resize / delete) and
31
+ // renders a lock badge on its top-right corner. Absent → unlocked default.
32
+ // Mirrored explicitly into IconNodeDataSchema below
33
+ // since that variant doesn't spread this base shape.
34
+ const NodeVisualBaseShape = {
35
+ width: z.number().positive().optional(),
36
+ height: z.number().positive().optional(),
37
+ borderColor: ColorTokenSchema.optional(),
38
+ backgroundColor: ColorTokenSchema.optional(),
39
+ borderSize: z.number().positive().optional(),
40
+ borderStyle: z.enum(['solid', 'dashed', 'dotted']).optional(),
41
+ fontSize: z.number().positive().optional(),
42
+ textColor: ColorTokenSchema.optional(),
43
+ cornerRadius: z.number().min(0).optional(),
44
+ locked: z.boolean().optional(),
45
+ };
46
+
47
+ // Consolidated three-field metadata shared by every node variant. `description`
48
+ // is the short body text rendered on the canvas under the node header (and as
49
+ // light-bold text in the sidebar). `detail` is the long-form free-text body
50
+ // rendered only in the sidebar. Both optional so unset fields round-trip
51
+ // unchanged. Spread into every node-data schema below since Icon doesn't
52
+ // spread NodeVisualBaseShape.
53
+ const NodeDescriptionBaseShape = {
54
+ description: z.string().optional(),
55
+ detail: z.string().optional(),
56
+ };
57
+
58
+ // US-001: relative-path safety refine (textual). Mirrors the same rule used
59
+ // for image/html-node paths further down. Realpath verification is layered on
60
+ // top by the proxy/status-runner before any spawn (symlink-escape defense).
61
+ const isCleanRelativePath = (s: string): boolean => {
62
+ if (s.length === 0) return false;
63
+ if (s.startsWith('/') || s.startsWith('\\')) return false;
64
+ if (/^[A-Za-z]:[\\/]/.test(s)) return false;
65
+ const segments = s.split(/[\\/]/);
66
+ return !segments.some((seg) => seg === '..');
67
+ };
68
+
69
+ // Script-based action: the studio spawns `<interpreter> [...args] <scriptPath>`
70
+ // from the project's repoPath. `scriptPath` is a relative path under
71
+ // `<project>/.seeflow/`; `args` (optional) prepend to the interpreter; `input`
72
+ // (optional) gets JSON-serialized and written to the child's stdin then closed;
73
+ // `timeoutMs` caps execution (default applied at the spawn layer, not here).
74
+ const ScriptActionSchema = z.object({
75
+ kind: z.literal('script'),
76
+ interpreter: z.string().min(1),
77
+ args: z.array(z.string()).optional(),
78
+ scriptPath: z.string().min(1).refine(isCleanRelativePath, {
79
+ message: 'scriptPath must be a relative path under .seeflow/ (no absolute / traversal)',
80
+ }),
81
+ input: z.unknown().optional(),
82
+ timeoutMs: z.number().int().positive().max(600_000).optional(),
83
+ });
84
+
85
+ const PlayActionSchema = ScriptActionSchema;
86
+
87
+ // US-008: resetAction is a one-shot script action — same shape as a play
88
+ // script (interpreter + args + scriptPath + optional input/timeoutMs) but
89
+ // invoked from the /reset endpoint. The studio kills every live play and
90
+ // status script for the demo before running this script, so the running app
91
+ // sees a clean baseline when wiping its state.
92
+ const ResetActionSchema = ScriptActionSchema;
93
+
94
+ // Long-running status script. Same spawn shape as ScriptAction (interpreter +
95
+ // args + scriptPath) but no stdin payload and a much longer max lifetime since
96
+ // these processes tick continuously and stream StatusReports to stdout.
97
+ const StatusActionSchema = z.object({
98
+ kind: z.literal('script'),
99
+ interpreter: z.string().min(1),
100
+ args: z.array(z.string()).optional(),
101
+ scriptPath: z.string().min(1).refine(isCleanRelativePath, {
102
+ message: 'scriptPath must be a relative path under .seeflow/ (no absolute / traversal)',
103
+ }),
104
+ maxLifetimeMs: z.number().int().positive().max(3_600_000).optional(),
105
+ });
106
+
107
+ // Per-tick status report a statusAction script writes to stdout (one JSON
108
+ // record per line). `data` is a free-form key/value bag rendered as a table
109
+ // in the sidebar.
110
+ export const StatusReportSchema = z.object({
111
+ state: z.enum(['ok', 'warn', 'error', 'pending']),
112
+ summary: z.string().max(120).optional(),
113
+ detail: z.string().max(2000).optional(),
114
+ data: z.record(z.string(), z.unknown()).optional(),
115
+ ts: z.number().int().positive().optional(),
116
+ });
117
+
118
+ const StateSourceSchema = z.discriminatedUnion('kind', [
119
+ z.object({ kind: z.literal('request') }),
120
+ z.object({ kind: z.literal('event') }),
121
+ ]);
122
+
123
+ const NodeDataBaseSchema = z.object({
124
+ name: z.string().min(1),
125
+ kind: z.string().min(1),
126
+ stateSource: StateSourceSchema,
127
+ // Reserved for v2: a module path resolved by future skills runtime.
128
+ // Schema-only at v1 — never read at runtime.
129
+ handlerModule: z.string().optional(),
130
+ ...NodeVisualBaseShape,
131
+ ...NodeDescriptionBaseShape,
132
+ });
133
+
134
+ const PlayNodeDataSchema = NodeDataBaseSchema.extend({
135
+ playAction: PlayActionSchema,
136
+ statusAction: StatusActionSchema.optional(),
137
+ });
138
+
139
+ const StateNodeDataSchema = NodeDataBaseSchema.extend({
140
+ playAction: PlayActionSchema.optional(),
141
+ statusAction: StatusActionSchema.optional(),
142
+ });
143
+
144
+ const NodeBaseShape = {
145
+ id: z.string().min(1),
146
+ position: PositionSchema,
147
+ };
148
+
149
+ const PlayNodeSchema = z.object({
150
+ ...NodeBaseShape,
151
+ type: z.literal('playNode'),
152
+ data: PlayNodeDataSchema,
153
+ });
154
+
155
+ const StateNodeSchema = z.object({
156
+ ...NodeBaseShape,
157
+ type: z.literal('stateNode'),
158
+ data: StateNodeDataSchema,
159
+ });
160
+
161
+ // Decorative annotation node — rectangle / ellipse / sticky. No semantic
162
+ // payload (no kind/stateSource/playAction); reuses NodeVisualBaseShape so
163
+ // users can theme it the same way as functional nodes.
164
+ // US-009 added `database` as the first illustrative shape (cylinder rendered
165
+ // via inline SVG inside shape-node.tsx). Illustrative shapes share the same
166
+ // shapeNode wrapper and color/border fields but own their own visuals via a
167
+ // per-shape component under `apps/web/src/components/nodes/shapes/`.
168
+ const ShapeKindSchema = z.enum([
169
+ 'rectangle',
170
+ 'ellipse',
171
+ 'sticky',
172
+ 'text',
173
+ 'database',
174
+ 'server',
175
+ 'user',
176
+ 'queue',
177
+ 'cloud',
178
+ ]);
179
+
180
+ const ShapeNodeDataSchema = z.object({
181
+ shape: ShapeKindSchema,
182
+ name: z.string().optional(),
183
+ ...NodeVisualBaseShape,
184
+ ...NodeDescriptionBaseShape,
185
+ });
186
+
187
+ const ShapeNodeSchema = z.object({
188
+ ...NodeBaseShape,
189
+ type: z.literal('shapeNode'),
190
+ data: ShapeNodeDataSchema,
191
+ });
192
+
193
+ // Decorative image node — references a file under `<project>/.seeflow/` by
194
+ // relative path (US-004 hard-cut from base64 data URLs to path-backed files).
195
+ // `path` is the same kind of relative path as `htmlPath` on htmlNode: rooted
196
+ // at `.seeflow/`, no leading slash, no `..` segments. The renderer fetches via
197
+ // `GET /api/projects/:id/files/:path`.
198
+ const ImageNodeDataSchema = z.object({
199
+ path: z.string().min(1).refine(isCleanRelativePath, {
200
+ message: 'path must be a relative path under .seeflow/ (no absolute / traversal)',
201
+ }),
202
+ alt: z.string().optional(),
203
+ ...NodeVisualBaseShape,
204
+ ...NodeDescriptionBaseShape,
205
+ borderWidth: z.number().min(1).max(8).optional(),
206
+ });
207
+
208
+ const ImageNodeSchema = z.object({
209
+ ...NodeBaseShape,
210
+ type: z.literal('imageNode'),
211
+ data: ImageNodeDataSchema,
212
+ });
213
+
214
+ // US-011 (illustrative-shapes-htmlnode): htmlNode is the escape-hatch node type
215
+ // for content the curated nodes don't cover — references author-written HTML at
216
+ // `<project>/.seeflow/<htmlPath>`. The renderer fetches via the file-serving
217
+ // endpoint and sanitizes before injecting (US-013/US-014). `htmlPath` uses the
218
+ // same path-safety refine as imageNode.path: relative under `.seeflow/`, no
219
+ // absolute root, no `..` traversal. Spreads NodeVisualBaseShape so authors can
220
+ // theme the wrapper (border / background / radius / font) with the same fields
221
+ // available on every other visual node.
222
+ //
223
+ // File existence is INTENTIONALLY not validated at the schema level. Missing
224
+ // files are a normal authoring state (author drops a node, file hasn't been
225
+ // written yet) and would otherwise reject the whole demo. The US-014 renderer
226
+ // renders a `PlaceholderCard` instead — so a missing htmlPath WARNS (via the
227
+ // placeholder visual) without ERRORING (without failing demo parse).
228
+ const HtmlNodeDataSchema = z.object({
229
+ htmlPath: z.string().min(1).refine(isCleanRelativePath, {
230
+ message: 'htmlPath must be a relative path under .seeflow/ (no absolute / traversal)',
231
+ }),
232
+ name: z.string().optional(),
233
+ ...NodeVisualBaseShape,
234
+ ...NodeDescriptionBaseShape,
235
+ });
236
+
237
+ const HtmlNodeSchema = z.object({
238
+ ...NodeBaseShape,
239
+ type: z.literal('htmlNode'),
240
+ data: HtmlNodeDataSchema,
241
+ });
242
+
243
+ // Decorative icon node — renders a Lucide glyph on the canvas. Unboxed
244
+ // (no border/cornerRadius/backgroundColor) so it does NOT spread
245
+ // NodeVisualBaseShape; only `width` / `height` are reused for resizing.
246
+ const IconNodeDataSchema = z.object({
247
+ icon: z.string().min(1),
248
+ color: ColorTokenSchema.optional(),
249
+ strokeWidth: z.number().min(0.5).max(4).optional(),
250
+ width: z.number().positive().optional(),
251
+ height: z.number().positive().optional(),
252
+ alt: z.string().optional(),
253
+ // US-002: optional visible caption rendered below the icon. Distinct from
254
+ // `alt` (screen-reader text). Absent / empty → no caption rendered and the
255
+ // node's bounding box is byte-identical to the unlabeled layout.
256
+ name: z.string().optional(),
257
+ // US-019: lock state mirror of NodeVisualBaseShape.locked. IconNode does
258
+ // not spread the visual base so we declare it here explicitly.
259
+ locked: z.boolean().optional(),
260
+ ...NodeDescriptionBaseShape,
261
+ });
262
+
263
+ const IconNodeSchema = z.object({
264
+ ...NodeBaseShape,
265
+ type: z.literal('iconNode'),
266
+ data: IconNodeDataSchema,
267
+ });
268
+
269
+ const NodeSchema = z.discriminatedUnion('type', [
270
+ PlayNodeSchema,
271
+ StateNodeSchema,
272
+ ShapeNodeSchema,
273
+ ImageNodeSchema,
274
+ IconNodeSchema,
275
+ HtmlNodeSchema,
276
+ ]);
277
+
278
+ // Connector is the semantic edge between two nodes — describes HOW they are
279
+ // connected, not just THAT they are. Discriminated on `kind`:
280
+ // • http — service-to-service HTTP call (method + url echo of the playAction)
281
+ // • event — pub/sub event (eventName)
282
+ // • queue — message-queue handoff (queueName)
283
+ // • default — user-drawn, no semantic payload (UI annotation only)
284
+ // The frontend derives a React Flow Edge from each connector at render time
285
+ // (id/source/target are reused; `label` becomes the edge label; visual style
286
+ // is picked from `kind`, but per-connector `style`/`color` overrides it). v1
287
+ // has no separate `edges[]` array — connectors are the sole source of truth
288
+ // for inter-node connections.
289
+ const ConnectorStyleSchema = z.enum(['solid', 'dashed', 'dotted']);
290
+ const ConnectorDirectionSchema = z.enum(['forward', 'backward', 'both', 'none']);
291
+ // Path geometry — orthogonal to `style` (which means the dash pattern). Absent
292
+ // → renders as today's smooth bezier curve. 'step' renders as a smoothstep
293
+ // (right-angle / zigzag) path. (US-017)
294
+ const ConnectorPathSchema = z.enum(['curve', 'step']);
295
+
296
+ // Visual fields shared by every connector kind. All optional — existing
297
+ // demo files predate them and must continue to parse. `direction` defaults
298
+ // to 'forward' when absent (the historical behavior).
299
+ const ConnectorVisualBaseShape = {
300
+ style: ConnectorStyleSchema.optional(),
301
+ color: ColorTokenSchema.optional(),
302
+ direction: ConnectorDirectionSchema.optional(),
303
+ borderSize: z.number().positive().optional(),
304
+ path: ConnectorPathSchema.optional(),
305
+ // US-018: per-connector label font size in CSS pixels. Absent → fall back to
306
+ // the editable-edge default (11px). Mirrors NodeVisualBaseShape.fontSize.
307
+ fontSize: z.number().positive().optional(),
308
+ };
309
+
310
+ // Handle ids — every node kind in this codebase uses the same four-handle
311
+ // layout: target-only on top + left, source-only on right + bottom (US-013).
312
+ // `sourceHandle` MUST be a source-side id and `targetHandle` MUST be a
313
+ // target-side id; sending the wrong role leaves a stranded endpoint at render
314
+ // time, so the schema rejects it (US-022).
315
+ export const SourceHandleIdSchema = z.enum(['r', 'b']);
316
+ export const TargetHandleIdSchema = z.enum(['t', 'l']);
317
+
318
+ // US-006: pinned endpoint position. `side` names one of the four perimeter
319
+ // sides of the connected node; `t` is the parameterized position along that
320
+ // side, [0, 1], measured from the top-left corner of the side (top/bottom →
321
+ // left-to-right; left/right → top-to-bottom). Pins are persisted so they
322
+ // survive node moves and resizes without drifting toward the other endpoint's
323
+ // center the way floating endpoints do.
324
+ const EdgePinSideSchema = z.enum(['top', 'right', 'bottom', 'left']);
325
+ export const EdgePinSchema = z.object({
326
+ side: EdgePinSideSchema,
327
+ t: z.number().min(0).max(1),
328
+ });
329
+
330
+ const ConnectorBaseShape = {
331
+ id: z.string().min(1),
332
+ source: z.string().min(1),
333
+ target: z.string().min(1),
334
+ // Optional — connectors authored before the four-handle layout omit them and
335
+ // React Flow falls back to the first matching handle.
336
+ sourceHandle: SourceHandleIdSchema.optional(),
337
+ targetHandle: TargetHandleIdSchema.optional(),
338
+ // US-021: tracks whether each endpoint's handle was auto-picked by the
339
+ // facing-handle picker (true) or pinned by an explicit user handle drop
340
+ // (false / absent). Auto-picked endpoints get re-routed when nodes move so
341
+ // the connector keeps facing the other end; user-pinned ones never do.
342
+ sourceHandleAutoPicked: z.boolean().optional(),
343
+ targetHandleAutoPicked: z.boolean().optional(),
344
+ // US-006: optional explicit perimeter positions for each endpoint. When
345
+ // set, the endpoint is computed from `(side, t)` against the connected
346
+ // node's current bbox at render time — the position parameterizes with the
347
+ // node so the pin survives moves and resizes. Absent → floating /
348
+ // handle-based endpoint behavior (back-compat).
349
+ sourcePin: EdgePinSchema.optional(),
350
+ targetPin: EdgePinSchema.optional(),
351
+ label: z.string().optional(),
352
+ ...ConnectorVisualBaseShape,
353
+ };
354
+
355
+ const HttpConnectorSchema = z.object({
356
+ ...ConnectorBaseShape,
357
+ kind: z.literal('http'),
358
+ method: HttpMethodSchema.optional(),
359
+ url: z.string().min(1).optional(),
360
+ });
361
+
362
+ const EventConnectorSchema = z.object({
363
+ ...ConnectorBaseShape,
364
+ kind: z.literal('event'),
365
+ eventName: z.string().min(1),
366
+ });
367
+
368
+ const QueueConnectorSchema = z.object({
369
+ ...ConnectorBaseShape,
370
+ kind: z.literal('queue'),
371
+ queueName: z.string().min(1),
372
+ });
373
+
374
+ const DefaultConnectorSchema = z.object({
375
+ ...ConnectorBaseShape,
376
+ kind: z.literal('default'),
377
+ });
378
+
379
+ const ConnectorSchema = z.discriminatedUnion('kind', [
380
+ HttpConnectorSchema,
381
+ EventConnectorSchema,
382
+ QueueConnectorSchema,
383
+ DefaultConnectorSchema,
384
+ ]);
385
+
386
+ export const DemoSchema = z
387
+ .object({
388
+ version: z.literal(1),
389
+ name: z.string().min(1),
390
+ nodes: z.array(NodeSchema),
391
+ connectors: z.array(ConnectorSchema),
392
+ // Optional one-shot script the studio runs when the user clicks Restart.
393
+ // Lets the running app wipe its own in-memory state. The studio kills
394
+ // every live play + status script for the demo BEFORE invoking this
395
+ // script (US-008), so the script sees no stragglers.
396
+ resetAction: ResetActionSchema.optional(),
397
+ })
398
+ .superRefine((demo, ctx) => {
399
+ const nodeIds = new Set(demo.nodes.map((n) => n.id));
400
+ demo.connectors.forEach((c, idx) => {
401
+ if (!nodeIds.has(c.source)) {
402
+ ctx.addIssue({
403
+ code: z.ZodIssueCode.custom,
404
+ path: ['connectors', idx, 'source'],
405
+ message: `Connector ${c.id} references unknown source node: ${c.source}`,
406
+ });
407
+ }
408
+ if (!nodeIds.has(c.target)) {
409
+ ctx.addIssue({
410
+ code: z.ZodIssueCode.custom,
411
+ path: ['connectors', idx, 'target'],
412
+ message: `Connector ${c.id} references unknown target node: ${c.target}`,
413
+ });
414
+ }
415
+ });
416
+ });
417
+
418
+ export type Demo = z.infer<typeof DemoSchema>;
419
+ export type DemoNode = z.infer<typeof NodeSchema>;
420
+ export type ShapeNode = z.infer<typeof ShapeNodeSchema>;
421
+ export type ImageNode = z.infer<typeof ImageNodeSchema>;
422
+ export type IconNode = z.infer<typeof IconNodeSchema>;
423
+ export type HtmlNode = z.infer<typeof HtmlNodeSchema>;
424
+ export type HtmlNodeData = z.infer<typeof HtmlNodeDataSchema>;
425
+ export type ShapeKind = z.infer<typeof ShapeKindSchema>;
426
+ export type ColorToken = z.infer<typeof ColorTokenSchema>;
427
+ export type Connector = z.infer<typeof ConnectorSchema>;
428
+ export type HttpConnector = z.infer<typeof HttpConnectorSchema>;
429
+ export type EventConnector = z.infer<typeof EventConnectorSchema>;
430
+ export type QueueConnector = z.infer<typeof QueueConnectorSchema>;
431
+ export type DefaultConnector = z.infer<typeof DefaultConnectorSchema>;
432
+ export type ConnectorStyle = z.infer<typeof ConnectorStyleSchema>;
433
+ export type ConnectorDirection = z.infer<typeof ConnectorDirectionSchema>;
434
+ export type ConnectorPath = z.infer<typeof ConnectorPathSchema>;
435
+ export type EdgePin = z.infer<typeof EdgePinSchema>;
436
+ export type EdgePinSide = z.infer<typeof EdgePinSideSchema>;
437
+ export type PlayAction = z.infer<typeof PlayActionSchema>;
438
+ export type StatusAction = z.infer<typeof StatusActionSchema>;
439
+ export type StatusReport = z.infer<typeof StatusReportSchema>;
440
+ export type ResetAction = z.infer<typeof ResetActionSchema>;
441
+ export type StateSource = z.infer<typeof StateSourceSchema>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Self-contained `emit()` helper copied verbatim into a target repo's
3
+ * `.seeflow/sdk/emit.ts` by `seeflow register` when the demo declares any
4
+ * event-bound state node. Kept dependency-free so the user's app can call it
5
+ * without installing `@seeflow/sdk`.
6
+ */
7
+ export const EMIT_TEMPLATE = `// Auto-generated by seeflow register. Safe to commit; safe to delete.
8
+ // Re-running register will not overwrite this file.
9
+ export type EmitStatus = 'running' | 'done' | 'error';
10
+
11
+ export interface EmitOptions {
12
+ runId?: string;
13
+ payload?: unknown;
14
+ studioUrl?: string;
15
+ }
16
+
17
+ const readEnv = (key: string): string | undefined => {
18
+ const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
19
+ return proc?.env?.[key];
20
+ };
21
+
22
+ export async function emit(
23
+ demoId: string,
24
+ nodeId: string,
25
+ status: EmitStatus,
26
+ opts: EmitOptions = {},
27
+ ): Promise<void> {
28
+ const base = (
29
+ opts.studioUrl ?? readEnv('SEEFLOW_STUDIO_URL') ?? 'http://localhost:4321'
30
+ ).replace(/\\/+$/, '');
31
+ await fetch(\`\${base}/api/emit\`, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ demoId, nodeId, status, runId: opts.runId, payload: opts.payload }),
35
+ });
36
+ }
37
+ `;
@@ -0,0 +1,37 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Demo } from './schema.ts';
4
+ import { EMIT_TEMPLATE } from './sdk-template.ts';
5
+
6
+ export type SdkWriteOutcome = 'skipped' | 'written' | 'present';
7
+
8
+ export interface SdkWriteResult {
9
+ outcome: SdkWriteOutcome;
10
+ /** Absolute path of the SDK file (written or pre-existing). Null on `skipped`. */
11
+ filePath: string | null;
12
+ }
13
+
14
+ /**
15
+ * Writes `.seeflow/sdk/emit.ts` into a target repo iff the demo declares any
16
+ * node with `stateSource.kind === 'event'`. Idempotent: existing files are
17
+ * never overwritten. The only place M1's CLI mutates a user repo.
18
+ */
19
+ export function writeSdkEmitIfNeeded(repoPath: string, demo: Demo): SdkWriteResult {
20
+ const hasEventState = demo.nodes.some(
21
+ (n) =>
22
+ n.type !== 'shapeNode' &&
23
+ n.type !== 'imageNode' &&
24
+ n.type !== 'iconNode' &&
25
+ n.type !== 'htmlNode' &&
26
+ n.data.stateSource.kind === 'event',
27
+ );
28
+ if (!hasEventState) return { outcome: 'skipped', filePath: null };
29
+
30
+ const sdkDir = join(repoPath, '.seeflow', 'sdk');
31
+ const filePath = join(sdkDir, 'emit.ts');
32
+ if (existsSync(filePath)) return { outcome: 'present', filePath };
33
+
34
+ mkdirSync(sdkDir, { recursive: true });
35
+ writeFileSync(filePath, EMIT_TEMPLATE);
36
+ return { outcome: 'written', filePath };
37
+ }