@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
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
// Shared inner helpers that REST handlers in api.ts and MCP tool handlers in
|
|
2
|
+
// mcp.ts both call. Each helper returns an Outcome discriminated union so the
|
|
3
|
+
// caller layer can translate it into its native response shape (HTTP status
|
|
4
|
+
// vs. MCP CallToolResult) without duplicating any of the business logic.
|
|
5
|
+
//
|
|
6
|
+
// Helpers extracted in US-002: discovery + project setup (5 tools).
|
|
7
|
+
// Helpers extracted in US-003: node lifecycle (add/delete/move/reorder).
|
|
8
|
+
// Future stories add patch_node + connector helpers alongside these.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
13
|
+
import { type ZodIssue, z } from 'zod';
|
|
14
|
+
import { type Registry, slugify } from './registry.ts';
|
|
15
|
+
import {
|
|
16
|
+
ColorTokenSchema,
|
|
17
|
+
type Demo,
|
|
18
|
+
DemoSchema,
|
|
19
|
+
EdgePinSchema,
|
|
20
|
+
SourceHandleIdSchema,
|
|
21
|
+
TargetHandleIdSchema,
|
|
22
|
+
} from './schema.ts';
|
|
23
|
+
import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
|
|
24
|
+
import type { DemoSnapshot, DemoWatcher } from './watcher.ts';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_DEMO_RELATIVE_PATH = '.seeflow/seeflow.json';
|
|
27
|
+
|
|
28
|
+
export const RegisterBodySchema = z.object({
|
|
29
|
+
name: z.string().min(1).optional(),
|
|
30
|
+
repoPath: z.string().min(1),
|
|
31
|
+
demoPath: z.string().min(1),
|
|
32
|
+
});
|
|
33
|
+
export type RegisterBody = z.infer<typeof RegisterBodySchema>;
|
|
34
|
+
|
|
35
|
+
export const CreateProjectBodySchema = z.object({
|
|
36
|
+
name: z.string().min(1),
|
|
37
|
+
});
|
|
38
|
+
export type CreateProjectBody = z.infer<typeof CreateProjectBodySchema>;
|
|
39
|
+
|
|
40
|
+
export const PositionBodySchema = z.object({
|
|
41
|
+
x: z.number().finite(),
|
|
42
|
+
y: z.number().finite(),
|
|
43
|
+
});
|
|
44
|
+
export type PositionBody = z.infer<typeof PositionBodySchema>;
|
|
45
|
+
|
|
46
|
+
// Reorder a node within `demo.nodes[]`. The four ops mirror the typical
|
|
47
|
+
// "send backward / bring forward / to back / to front" actions; `toIndex`
|
|
48
|
+
// pins the node back to a captured absolute index so undo for `forward` /
|
|
49
|
+
// `backward` from the middle is faithful even under concurrent edits.
|
|
50
|
+
export const ReorderBodySchema = z.discriminatedUnion('op', [
|
|
51
|
+
z.object({ op: z.literal('forward') }),
|
|
52
|
+
z.object({ op: z.literal('backward') }),
|
|
53
|
+
z.object({ op: z.literal('toFront') }),
|
|
54
|
+
z.object({ op: z.literal('toBack') }),
|
|
55
|
+
z.object({ op: z.literal('toIndex'), index: z.number().int().nonnegative() }),
|
|
56
|
+
]);
|
|
57
|
+
export type ReorderBody = z.infer<typeof ReorderBodySchema>;
|
|
58
|
+
|
|
59
|
+
// Partial node update body. Top-level `position` lands on node.position; every
|
|
60
|
+
// other key lands inside node.data. Final validity is enforced by re-parsing
|
|
61
|
+
// the whole demo through DemoSchema after the merge — this body schema just
|
|
62
|
+
// rejects unknown top-level keys to catch typos.
|
|
63
|
+
export const NodePatchBodySchema = z
|
|
64
|
+
.object({
|
|
65
|
+
position: PositionBodySchema.optional(),
|
|
66
|
+
name: z.string().optional(),
|
|
67
|
+
borderColor: ColorTokenSchema.optional(),
|
|
68
|
+
backgroundColor: ColorTokenSchema.optional(),
|
|
69
|
+
borderSize: z.number().positive().optional(),
|
|
70
|
+
borderWidth: z.number().min(1).max(8).optional(),
|
|
71
|
+
borderStyle: z.enum(['solid', 'dashed', 'dotted']).optional(),
|
|
72
|
+
fontSize: z.number().positive().optional(),
|
|
73
|
+
textColor: ColorTokenSchema.optional(),
|
|
74
|
+
cornerRadius: z.number().min(0).optional(),
|
|
75
|
+
width: z.number().positive().optional(),
|
|
76
|
+
height: z.number().positive().optional(),
|
|
77
|
+
shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
|
|
78
|
+
// iconNode-only: stroke color token. Lands at data.color; DemoSchema's
|
|
79
|
+
// post-merge reparse gates that this is only valid on an iconNode.
|
|
80
|
+
color: ColorTokenSchema.optional(),
|
|
81
|
+
// iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
|
|
82
|
+
// post-merge reparse gates the [0.5, 4] bound and arm validity.
|
|
83
|
+
strokeWidth: z.number().min(0.5).max(4).optional(),
|
|
84
|
+
// iconNode-only: accessible alt text for the icon. Lands at data.alt.
|
|
85
|
+
alt: z.string().optional(),
|
|
86
|
+
// iconNode-only: kebab-case Lucide icon name. Lands at data.icon. The
|
|
87
|
+
// post-merge reparse enforces the schema's `.min(1)` non-empty rule and
|
|
88
|
+
// gates that this lands only on an iconNode.
|
|
89
|
+
icon: z.string().min(1).optional(),
|
|
90
|
+
// US-019: lock state. Lands at data.locked; persists across save/reload.
|
|
91
|
+
// Absent → unlocked default (no badge, all gestures work).
|
|
92
|
+
locked: z.boolean().optional(),
|
|
93
|
+
// Three-field consolidation: free-text metadata on every node variant.
|
|
94
|
+
// Empty string on `description` or `detail` is the documented clear-on-
|
|
95
|
+
// serialize signal — `mergeNodeUpdates` strips the key from disk.
|
|
96
|
+
description: z.string().optional(),
|
|
97
|
+
detail: z.string().optional(),
|
|
98
|
+
})
|
|
99
|
+
.strict();
|
|
100
|
+
export type NodePatchBody = z.infer<typeof NodePatchBodySchema>;
|
|
101
|
+
|
|
102
|
+
// Apply a partial PATCH body to a raw on-disk node. `position` lives at the
|
|
103
|
+
// node root; every other key lives inside `data`. We mutate the raw parsed
|
|
104
|
+
// JSON directly so unknown forward-compat fields the schema doesn't yet
|
|
105
|
+
// recognize survive the round-trip untouched.
|
|
106
|
+
const NODE_DATA_PATCH_KEYS = [
|
|
107
|
+
'name',
|
|
108
|
+
'borderColor',
|
|
109
|
+
'backgroundColor',
|
|
110
|
+
'borderSize',
|
|
111
|
+
'borderWidth',
|
|
112
|
+
'borderStyle',
|
|
113
|
+
'fontSize',
|
|
114
|
+
'textColor',
|
|
115
|
+
'cornerRadius',
|
|
116
|
+
'width',
|
|
117
|
+
'height',
|
|
118
|
+
'shape',
|
|
119
|
+
'color',
|
|
120
|
+
'strokeWidth',
|
|
121
|
+
'alt',
|
|
122
|
+
'icon',
|
|
123
|
+
'locked',
|
|
124
|
+
'description',
|
|
125
|
+
'detail',
|
|
126
|
+
] as const satisfies ReadonlyArray<keyof NodePatchBody>;
|
|
127
|
+
|
|
128
|
+
export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePatchBody): void => {
|
|
129
|
+
if (updates.position !== undefined) {
|
|
130
|
+
node.position = updates.position;
|
|
131
|
+
}
|
|
132
|
+
const dataAny = node.data;
|
|
133
|
+
const data: Record<string, unknown> =
|
|
134
|
+
dataAny && typeof dataAny === 'object' && !Array.isArray(dataAny)
|
|
135
|
+
? (dataAny as Record<string, unknown>)
|
|
136
|
+
: {};
|
|
137
|
+
let touchedData = false;
|
|
138
|
+
for (const key of NODE_DATA_PATCH_KEYS) {
|
|
139
|
+
if (updates[key] === undefined) continue;
|
|
140
|
+
// Empty string on the two free-text metadata fields is the documented
|
|
141
|
+
// clear-on-serialize signal — strip the key instead of writing "" to disk
|
|
142
|
+
// so seeflow.json stays compact and round-tripping a cleared node doesn't
|
|
143
|
+
// reintroduce the field.
|
|
144
|
+
if ((key === 'description' || key === 'detail') && updates[key] === '') {
|
|
145
|
+
if (key in data) {
|
|
146
|
+
delete data[key];
|
|
147
|
+
touchedData = true;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
data[key] = updates[key];
|
|
152
|
+
touchedData = true;
|
|
153
|
+
}
|
|
154
|
+
if (touchedData) {
|
|
155
|
+
node.data = data;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export interface OperationsDeps {
|
|
160
|
+
registry: Registry;
|
|
161
|
+
watcher?: DemoWatcher;
|
|
162
|
+
/** Override the base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
|
|
163
|
+
projectBaseDir?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface DemoListItem {
|
|
167
|
+
id: string;
|
|
168
|
+
slug: string;
|
|
169
|
+
name: string;
|
|
170
|
+
repoPath: string;
|
|
171
|
+
lastModified: number;
|
|
172
|
+
valid: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface DemoGetResponse {
|
|
176
|
+
id: string;
|
|
177
|
+
slug: string;
|
|
178
|
+
name: string;
|
|
179
|
+
filePath: string;
|
|
180
|
+
demo: Demo | null;
|
|
181
|
+
valid: boolean;
|
|
182
|
+
error: string | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface RegisterDemoSuccess {
|
|
186
|
+
id: string;
|
|
187
|
+
slug: string;
|
|
188
|
+
sdk: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface CreateProjectSuccess {
|
|
192
|
+
id: string;
|
|
193
|
+
slug: string;
|
|
194
|
+
scaffolded: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type ListDemosOutcome = { kind: 'ok'; data: DemoListItem[] };
|
|
198
|
+
|
|
199
|
+
export type GetDemoOutcome =
|
|
200
|
+
| { kind: 'ok'; data: DemoGetResponse }
|
|
201
|
+
| { kind: 'notFound' }
|
|
202
|
+
| { kind: 'fileNotFound'; path: string };
|
|
203
|
+
|
|
204
|
+
export type RegisterDemoOutcome =
|
|
205
|
+
| { kind: 'ok'; data: RegisterDemoSuccess }
|
|
206
|
+
| { kind: 'fileNotFound'; path: string }
|
|
207
|
+
| { kind: 'badJson'; detail: string }
|
|
208
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
209
|
+
| { kind: 'sdkWriteFailed'; id: string; slug: string; message: string };
|
|
210
|
+
|
|
211
|
+
export type DeleteDemoOutcome = { kind: 'ok' } | { kind: 'notFound' };
|
|
212
|
+
|
|
213
|
+
export type CreateProjectOutcome =
|
|
214
|
+
| { kind: 'ok'; data: CreateProjectSuccess }
|
|
215
|
+
| { kind: 'badJson'; detail: string }
|
|
216
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
217
|
+
| { kind: 'scaffoldFailed'; message: string }
|
|
218
|
+
| { kind: 'sdkWriteFailed'; message: string };
|
|
219
|
+
|
|
220
|
+
// Outcomes for the four node-lifecycle helpers. Every variant lines up with
|
|
221
|
+
// an existing REST error response so api.ts can translate them back to the
|
|
222
|
+
// same status code + JSON body it used to emit directly.
|
|
223
|
+
export type AddNodeOutcome =
|
|
224
|
+
| { kind: 'ok'; data: { id: string; node: Record<string, unknown> } }
|
|
225
|
+
| { kind: 'demoNotFound' }
|
|
226
|
+
| { kind: 'fileNotFound'; path: string }
|
|
227
|
+
| { kind: 'badJson'; message: string }
|
|
228
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
229
|
+
| { kind: 'writeFailed'; message: string };
|
|
230
|
+
|
|
231
|
+
export type DeleteNodeOutcome =
|
|
232
|
+
| { kind: 'ok' }
|
|
233
|
+
| { kind: 'demoNotFound' }
|
|
234
|
+
| { kind: 'fileNotFound'; path: string }
|
|
235
|
+
| { kind: 'badJson'; message: string }
|
|
236
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
237
|
+
| { kind: 'unknownNode' }
|
|
238
|
+
| { kind: 'writeFailed'; message: string };
|
|
239
|
+
|
|
240
|
+
export type MoveNodeOutcome =
|
|
241
|
+
| { kind: 'ok'; data: { position: PositionBody } }
|
|
242
|
+
| { kind: 'demoNotFound' }
|
|
243
|
+
| { kind: 'fileNotFound'; path: string }
|
|
244
|
+
| { kind: 'badJson'; message: string }
|
|
245
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
246
|
+
| { kind: 'unknownNode' }
|
|
247
|
+
| { kind: 'writeFailed'; message: string };
|
|
248
|
+
|
|
249
|
+
export type ReorderNodeOutcome =
|
|
250
|
+
| { kind: 'ok' }
|
|
251
|
+
| { kind: 'demoNotFound' }
|
|
252
|
+
| { kind: 'fileNotFound'; path: string }
|
|
253
|
+
| { kind: 'badJson'; message: string }
|
|
254
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
255
|
+
| { kind: 'unknownNode' }
|
|
256
|
+
| { kind: 'writeFailed'; message: string };
|
|
257
|
+
|
|
258
|
+
export type PatchNodeOutcome =
|
|
259
|
+
| { kind: 'ok' }
|
|
260
|
+
| { kind: 'demoNotFound' }
|
|
261
|
+
| { kind: 'fileNotFound'; path: string }
|
|
262
|
+
| { kind: 'badJson'; message: string }
|
|
263
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
264
|
+
| { kind: 'unknownNode' }
|
|
265
|
+
| { kind: 'writeFailed'; message: string };
|
|
266
|
+
|
|
267
|
+
// Partial connector update body. Strict at the top level so client typos
|
|
268
|
+
// surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
|
|
269
|
+
// are enforced post-merge by re-parsing the whole demo through DemoSchema.
|
|
270
|
+
const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
|
|
271
|
+
export const ConnectorPatchBodySchema = z
|
|
272
|
+
.object({
|
|
273
|
+
label: z.string().optional(),
|
|
274
|
+
style: z.enum(['solid', 'dashed', 'dotted']).optional(),
|
|
275
|
+
color: ColorTokenSchema.optional(),
|
|
276
|
+
direction: z.enum(['forward', 'backward', 'both', 'none']).optional(),
|
|
277
|
+
borderSize: z.number().positive().optional(),
|
|
278
|
+
path: z.enum(['curve', 'step']).optional(),
|
|
279
|
+
// US-018: per-connector label font size (mirrors NodeVisualBaseShape.fontSize).
|
|
280
|
+
fontSize: z.number().positive().optional(),
|
|
281
|
+
kind: ConnectorKindSchema.optional(),
|
|
282
|
+
eventName: z.string().optional(),
|
|
283
|
+
queueName: z.string().optional(),
|
|
284
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
|
|
285
|
+
url: z.string().optional(),
|
|
286
|
+
// Reconnect: drag an edge endpoint onto another node's handle. The
|
|
287
|
+
// post-merge DemoSchema parse rejects dangling references, so we don't
|
|
288
|
+
// need a referential check here.
|
|
289
|
+
source: z.string().min(1).optional(),
|
|
290
|
+
target: z.string().min(1).optional(),
|
|
291
|
+
// Reconnect to a different handle on the same (or a new) node. Handle ids
|
|
292
|
+
// identify which side (top/right/bottom/left) of the node the connector
|
|
293
|
+
// attaches to (US-013); the role is locked — `sourceHandle` must be a
|
|
294
|
+
// source-side id, `targetHandle` must be a target-side id (US-022).
|
|
295
|
+
// Nullable so a body-drop reconnect (US-025) can clear a previously-pinned
|
|
296
|
+
// handle id by sending `null`; mergeConnectorUpdates deletes the field
|
|
297
|
+
// when the value is null.
|
|
298
|
+
sourceHandle: SourceHandleIdSchema.nullable().optional(),
|
|
299
|
+
targetHandle: TargetHandleIdSchema.nullable().optional(),
|
|
300
|
+
// US-021: auto-pick flags. Originally written by the picker on body-drop
|
|
301
|
+
// create / reconnect. US-025 keeps the schema shape but redefines the
|
|
302
|
+
// semantics: `true`/absent means "render floating" against the line
|
|
303
|
+
// through the two node centers; `false` means "render pinned to the
|
|
304
|
+
// stored handle id".
|
|
305
|
+
sourceHandleAutoPicked: z.boolean().optional(),
|
|
306
|
+
targetHandleAutoPicked: z.boolean().optional(),
|
|
307
|
+
// US-007: explicit perimeter pin for each endpoint. Sending an EdgePin
|
|
308
|
+
// pins the endpoint to `(side, t)` against the connected node's live
|
|
309
|
+
// bbox so it survives moves and resizes. Nullable so the right-click
|
|
310
|
+
// Unpin flow can clear a stored pin by sending `null`;
|
|
311
|
+
// mergeConnectorUpdates deletes the field when the value is null.
|
|
312
|
+
sourcePin: EdgePinSchema.nullable().optional(),
|
|
313
|
+
targetPin: EdgePinSchema.nullable().optional(),
|
|
314
|
+
})
|
|
315
|
+
.strict();
|
|
316
|
+
export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
|
|
317
|
+
|
|
318
|
+
// Kind-specific connector fields. When `kind` changes via PATCH, these are
|
|
319
|
+
// dropped first so the resulting connector doesn't carry phantom payloads
|
|
320
|
+
// from the previous kind (e.g. an event→default change leaving eventName
|
|
321
|
+
// behind, which DemoSchema would silently strip on parse but leave on disk).
|
|
322
|
+
const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
|
|
323
|
+
|
|
324
|
+
export const mergeConnectorUpdates = (
|
|
325
|
+
conn: Record<string, unknown>,
|
|
326
|
+
updates: ConnectorPatchBody,
|
|
327
|
+
): void => {
|
|
328
|
+
if (updates.kind !== undefined && updates.kind !== conn.kind) {
|
|
329
|
+
for (const key of CONNECTOR_KIND_FIELDS) {
|
|
330
|
+
delete conn[key];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
334
|
+
if (value === undefined) continue;
|
|
335
|
+
// US-025: explicit null in the patch body means "clear this field on
|
|
336
|
+
// disk". Used by reconnect-to-body to drop a previously-pinned handle
|
|
337
|
+
// id when the endpoint flips back to floating.
|
|
338
|
+
if (value === null) {
|
|
339
|
+
delete conn[key];
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
conn[key] = value;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export type AddConnectorOutcome =
|
|
347
|
+
| { kind: 'ok'; data: { id: string } }
|
|
348
|
+
| { kind: 'demoNotFound' }
|
|
349
|
+
| { kind: 'fileNotFound'; path: string }
|
|
350
|
+
| { kind: 'badJson'; message: string }
|
|
351
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
352
|
+
| { kind: 'writeFailed'; message: string };
|
|
353
|
+
|
|
354
|
+
export type PatchConnectorOutcome =
|
|
355
|
+
| { kind: 'ok' }
|
|
356
|
+
| { kind: 'demoNotFound' }
|
|
357
|
+
| { kind: 'fileNotFound'; path: string }
|
|
358
|
+
| { kind: 'badJson'; message: string }
|
|
359
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
360
|
+
| { kind: 'unknownConnector' }
|
|
361
|
+
| { kind: 'writeFailed'; message: string };
|
|
362
|
+
|
|
363
|
+
export type DeleteConnectorOutcome =
|
|
364
|
+
| { kind: 'ok' }
|
|
365
|
+
| { kind: 'demoNotFound' }
|
|
366
|
+
| { kind: 'fileNotFound'; path: string }
|
|
367
|
+
| { kind: 'badJson'; message: string }
|
|
368
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
369
|
+
| { kind: 'unknownConnector' }
|
|
370
|
+
| { kind: 'writeFailed'; message: string };
|
|
371
|
+
|
|
372
|
+
export const resolveDemoPath = (repoPath: string, demoPath: string): string =>
|
|
373
|
+
isAbsolute(demoPath) ? demoPath : join(repoPath, demoPath);
|
|
374
|
+
|
|
375
|
+
// Per-demo serialization: read-modify-write of the demo file isn't atomic
|
|
376
|
+
// across multiple PATCHes, so two concurrent drags would race (later writer's
|
|
377
|
+
// older read clobbers the earlier writer's update). We chain writes per
|
|
378
|
+
// demoId so the read+write sequence is effectively serialized.
|
|
379
|
+
const demoWriteChains = new Map<string, Promise<unknown>>();
|
|
380
|
+
export const withDemoWriteLock = <T>(demoId: string, fn: () => Promise<T>): Promise<T> => {
|
|
381
|
+
const prev = demoWriteChains.get(demoId) ?? Promise.resolve();
|
|
382
|
+
const next = prev.then(fn, fn);
|
|
383
|
+
// Replace with a tail that swallows errors so the chain keeps moving even
|
|
384
|
+
// if one write fails — but the original promise still rejects to its caller.
|
|
385
|
+
demoWriteChains.set(
|
|
386
|
+
demoId,
|
|
387
|
+
next.catch(() => undefined),
|
|
388
|
+
);
|
|
389
|
+
return next as Promise<T>;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Atomic write: writes to a sibling tempfile then renames over the target.
|
|
394
|
+
* `rename(2)` is atomic on POSIX, so a process reading mid-write either sees
|
|
395
|
+
* the old file or the new one — never a half-written one. This keeps user
|
|
396
|
+
* editor diffs clean (single fs.watch event for the rename) and means a crash
|
|
397
|
+
* during write can never corrupt the original.
|
|
398
|
+
*/
|
|
399
|
+
export const writeFileAtomic = (filePath: string, content: string): void => {
|
|
400
|
+
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
401
|
+
try {
|
|
402
|
+
writeFileSync(tempPath, content);
|
|
403
|
+
renameSync(tempPath, filePath);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
try {
|
|
406
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
407
|
+
} catch {
|
|
408
|
+
// best-effort cleanup
|
|
409
|
+
}
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const reorderNodes = (
|
|
415
|
+
nodes: Array<Record<string, unknown>>,
|
|
416
|
+
fromIdx: number,
|
|
417
|
+
body: ReorderBody,
|
|
418
|
+
): boolean => {
|
|
419
|
+
const len = nodes.length;
|
|
420
|
+
switch (body.op) {
|
|
421
|
+
case 'forward': {
|
|
422
|
+
if (fromIdx >= len - 1) return false;
|
|
423
|
+
const tmp = nodes[fromIdx];
|
|
424
|
+
const next = nodes[fromIdx + 1];
|
|
425
|
+
if (tmp === undefined || next === undefined) return false;
|
|
426
|
+
nodes[fromIdx] = next;
|
|
427
|
+
nodes[fromIdx + 1] = tmp;
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
case 'backward': {
|
|
431
|
+
if (fromIdx <= 0) return false;
|
|
432
|
+
const tmp = nodes[fromIdx];
|
|
433
|
+
const prev = nodes[fromIdx - 1];
|
|
434
|
+
if (tmp === undefined || prev === undefined) return false;
|
|
435
|
+
nodes[fromIdx] = prev;
|
|
436
|
+
nodes[fromIdx - 1] = tmp;
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
case 'toFront': {
|
|
440
|
+
if (fromIdx === len - 1) return false;
|
|
441
|
+
const [removed] = nodes.splice(fromIdx, 1);
|
|
442
|
+
if (removed === undefined) return false;
|
|
443
|
+
nodes.push(removed);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
case 'toBack': {
|
|
447
|
+
if (fromIdx === 0) return false;
|
|
448
|
+
const [removed] = nodes.splice(fromIdx, 1);
|
|
449
|
+
if (removed === undefined) return false;
|
|
450
|
+
nodes.unshift(removed);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
case 'toIndex': {
|
|
454
|
+
const target = Math.min(Math.max(body.index, 0), len - 1);
|
|
455
|
+
if (target === fromIdx) return false;
|
|
456
|
+
const [removed] = nodes.splice(fromIdx, 1);
|
|
457
|
+
if (removed === undefined) return false;
|
|
458
|
+
nodes.splice(target, 0, removed);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
export function listDemosImpl(deps: OperationsDeps): ListDemosOutcome {
|
|
465
|
+
const data = deps.registry.list().map((e) => {
|
|
466
|
+
const fullPath = resolveDemoPath(e.repoPath, e.demoPath);
|
|
467
|
+
const fileExists = existsSync(fullPath);
|
|
468
|
+
return {
|
|
469
|
+
id: e.id,
|
|
470
|
+
slug: e.slug,
|
|
471
|
+
name: e.name,
|
|
472
|
+
repoPath: e.repoPath,
|
|
473
|
+
lastModified: e.lastModified,
|
|
474
|
+
valid: e.valid && fileExists,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
return { kind: 'ok', data };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function getDemoImpl(deps: OperationsDeps, demoId: string): Promise<GetDemoOutcome> {
|
|
481
|
+
const { registry, watcher } = deps;
|
|
482
|
+
const entry = registry.getById(demoId);
|
|
483
|
+
if (!entry) return { kind: 'notFound' };
|
|
484
|
+
|
|
485
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
486
|
+
const snap = watcher?.snapshot(demoId) ?? watcher?.reparse(demoId) ?? null;
|
|
487
|
+
|
|
488
|
+
const buildResponse = (s: DemoSnapshot): DemoGetResponse => ({
|
|
489
|
+
id: entry.id,
|
|
490
|
+
slug: entry.slug,
|
|
491
|
+
name: entry.name,
|
|
492
|
+
filePath: fullPath,
|
|
493
|
+
demo: s.demo,
|
|
494
|
+
valid: s.valid,
|
|
495
|
+
error: s.valid ? null : s.error,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (snap) return { kind: 'ok', data: buildResponse(snap) };
|
|
499
|
+
|
|
500
|
+
// No watcher available — fall back to a synchronous read so MCP / CLI
|
|
501
|
+
// callers without a long-lived watcher still get a current snapshot.
|
|
502
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
503
|
+
|
|
504
|
+
let raw: unknown;
|
|
505
|
+
try {
|
|
506
|
+
raw = await Bun.file(fullPath).json();
|
|
507
|
+
} catch (err) {
|
|
508
|
+
return {
|
|
509
|
+
kind: 'ok',
|
|
510
|
+
data: buildResponse({
|
|
511
|
+
demo: null,
|
|
512
|
+
valid: false,
|
|
513
|
+
error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
514
|
+
filePath: fullPath,
|
|
515
|
+
parsedAt: Date.now(),
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const parsed = DemoSchema.safeParse(raw);
|
|
520
|
+
if (!parsed.success) {
|
|
521
|
+
return {
|
|
522
|
+
kind: 'ok',
|
|
523
|
+
data: buildResponse({
|
|
524
|
+
demo: null,
|
|
525
|
+
valid: false,
|
|
526
|
+
error: parsed.error.issues
|
|
527
|
+
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
528
|
+
.join('; '),
|
|
529
|
+
filePath: fullPath,
|
|
530
|
+
parsedAt: Date.now(),
|
|
531
|
+
}),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
kind: 'ok',
|
|
536
|
+
data: buildResponse({
|
|
537
|
+
demo: parsed.data,
|
|
538
|
+
valid: true,
|
|
539
|
+
error: null,
|
|
540
|
+
filePath: fullPath,
|
|
541
|
+
parsedAt: Date.now(),
|
|
542
|
+
}),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function registerDemoImpl(
|
|
547
|
+
deps: OperationsDeps,
|
|
548
|
+
body: RegisterBody,
|
|
549
|
+
): Promise<RegisterDemoOutcome> {
|
|
550
|
+
const { registry, watcher } = deps;
|
|
551
|
+
const { repoPath, demoPath } = body;
|
|
552
|
+
const fullPath = resolveDemoPath(repoPath, demoPath);
|
|
553
|
+
|
|
554
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
555
|
+
|
|
556
|
+
let demo: unknown;
|
|
557
|
+
try {
|
|
558
|
+
demo = await Bun.file(fullPath).json();
|
|
559
|
+
} catch (err) {
|
|
560
|
+
// REST uses String(err) here (preserves "SyntaxError: ..." prefix) —
|
|
561
|
+
// keep byte-identical so api.test.ts assertions stay green.
|
|
562
|
+
return { kind: 'badJson', detail: String(err) };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const demoParse = DemoSchema.safeParse(demo);
|
|
566
|
+
if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
|
|
567
|
+
|
|
568
|
+
const lastModified = statSync(fullPath).mtimeMs;
|
|
569
|
+
const entry = registry.upsert({
|
|
570
|
+
name: body.name ?? demoParse.data.name,
|
|
571
|
+
repoPath,
|
|
572
|
+
demoPath,
|
|
573
|
+
valid: true,
|
|
574
|
+
lastModified,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
watcher?.watch(entry.id);
|
|
578
|
+
|
|
579
|
+
let sdkResult: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
|
|
580
|
+
try {
|
|
581
|
+
sdkResult = writeSdkEmitIfNeeded(repoPath, demoParse.data);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
584
|
+
return { kind: 'sdkWriteFailed', id: entry.id, slug: entry.slug, message };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
kind: 'ok',
|
|
589
|
+
data: {
|
|
590
|
+
id: entry.id,
|
|
591
|
+
slug: entry.slug,
|
|
592
|
+
sdk: { outcome: sdkResult.outcome, filePath: sdkResult.filePath },
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function deleteDemoImpl(deps: OperationsDeps, idOrSlug: string): DeleteDemoOutcome {
|
|
598
|
+
const { registry, watcher } = deps;
|
|
599
|
+
const entry = registry.getById(idOrSlug) ?? registry.getBySlug(idOrSlug);
|
|
600
|
+
if (!entry) return { kind: 'notFound' };
|
|
601
|
+
watcher?.unwatch(entry.id);
|
|
602
|
+
registry.remove(entry.id);
|
|
603
|
+
return { kind: 'ok' };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function createProjectImpl(
|
|
607
|
+
deps: OperationsDeps,
|
|
608
|
+
body: CreateProjectBody,
|
|
609
|
+
): Promise<CreateProjectOutcome> {
|
|
610
|
+
const { registry, watcher } = deps;
|
|
611
|
+
const { name } = body;
|
|
612
|
+
const baseDir = deps.projectBaseDir ?? join(homedir(), '.seeflow');
|
|
613
|
+
const folderPath = join(baseDir, slugify(name));
|
|
614
|
+
|
|
615
|
+
const demoFullPath = join(folderPath, DEFAULT_DEMO_RELATIVE_PATH);
|
|
616
|
+
|
|
617
|
+
if (existsSync(demoFullPath)) {
|
|
618
|
+
let raw: unknown;
|
|
619
|
+
try {
|
|
620
|
+
raw = await Bun.file(demoFullPath).json();
|
|
621
|
+
} catch (err) {
|
|
622
|
+
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
623
|
+
}
|
|
624
|
+
const demoParse = DemoSchema.safeParse(raw);
|
|
625
|
+
if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
|
|
626
|
+
|
|
627
|
+
const lastModified = statSync(demoFullPath).mtimeMs;
|
|
628
|
+
const entry = registry.upsert({
|
|
629
|
+
name,
|
|
630
|
+
repoPath: folderPath,
|
|
631
|
+
demoPath: DEFAULT_DEMO_RELATIVE_PATH,
|
|
632
|
+
valid: true,
|
|
633
|
+
lastModified,
|
|
634
|
+
});
|
|
635
|
+
watcher?.watch(entry.id);
|
|
636
|
+
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const scaffold: Demo = { version: 1, name, nodes: [], connectors: [] };
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
mkdirSync(join(folderPath, '.seeflow'), { recursive: true });
|
|
643
|
+
writeFileSync(demoFullPath, `${JSON.stringify(scaffold, null, 2)}\n`);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return { kind: 'scaffoldFailed', message: err instanceof Error ? err.message : String(err) };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Same SDK-emit path as the CLI register flow. For a fresh scaffold with no
|
|
649
|
+
// event-bound state nodes this returns 'skipped' and writes nothing —
|
|
650
|
+
// retained for parity with `seeflow register`.
|
|
651
|
+
try {
|
|
652
|
+
writeSdkEmitIfNeeded(folderPath, scaffold);
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return { kind: 'sdkWriteFailed', message: err instanceof Error ? err.message : String(err) };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const lastModified = statSync(demoFullPath).mtimeMs;
|
|
658
|
+
const entry = registry.upsert({
|
|
659
|
+
name,
|
|
660
|
+
repoPath: folderPath,
|
|
661
|
+
demoPath: DEFAULT_DEMO_RELATIVE_PATH,
|
|
662
|
+
valid: true,
|
|
663
|
+
lastModified,
|
|
664
|
+
});
|
|
665
|
+
watcher?.watch(entry.id);
|
|
666
|
+
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Append a new node to the demo. Auto-generates an id when absent; DemoSchema
|
|
670
|
+
// is re-run on the post-mutation raw object before commit so a malformed
|
|
671
|
+
// payload never produces a half-written file.
|
|
672
|
+
export async function addNodeImpl(
|
|
673
|
+
deps: OperationsDeps,
|
|
674
|
+
demoId: string,
|
|
675
|
+
nodeBody: Record<string, unknown>,
|
|
676
|
+
): Promise<AddNodeOutcome> {
|
|
677
|
+
const entry = deps.registry.getById(demoId);
|
|
678
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
679
|
+
|
|
680
|
+
const newNode = { ...nodeBody };
|
|
681
|
+
if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
|
|
682
|
+
newNode.id = `node-${crypto.randomUUID()}`;
|
|
683
|
+
}
|
|
684
|
+
const newId = newNode.id as string;
|
|
685
|
+
|
|
686
|
+
// US-015: for htmlNode without a client-supplied htmlPath, allocate the
|
|
687
|
+
// studio-managed `blocks/<id>.html` path and queue a starter-file write.
|
|
688
|
+
// Client-supplied htmlPath wins and we skip the starter file (symmetric
|
|
689
|
+
// with US-016's hand-edited-path leave-alone rule).
|
|
690
|
+
let starterFile: { absPath: string; content: string } | undefined;
|
|
691
|
+
if (newNode.type === 'htmlNode') {
|
|
692
|
+
const dataIsRecord =
|
|
693
|
+
newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
|
|
694
|
+
const existingData: Record<string, unknown> = dataIsRecord
|
|
695
|
+
? { ...(newNode.data as Record<string, unknown>) }
|
|
696
|
+
: {};
|
|
697
|
+
const clientProvidedHtmlPath =
|
|
698
|
+
typeof existingData.htmlPath === 'string' && existingData.htmlPath.length > 0;
|
|
699
|
+
if (!clientProvidedHtmlPath) {
|
|
700
|
+
const htmlPath = `blocks/${newId}.html`;
|
|
701
|
+
existingData.htmlPath = htmlPath;
|
|
702
|
+
newNode.data = existingData;
|
|
703
|
+
starterFile = {
|
|
704
|
+
absPath: join(entry.repoPath, '.seeflow', htmlPath),
|
|
705
|
+
content: buildHtmlNodeStarter(newId),
|
|
706
|
+
};
|
|
707
|
+
} else if (!dataIsRecord) {
|
|
708
|
+
// Coerce non-object data into the spread'd record so the schema parse
|
|
709
|
+
// sees the right shape — shouldn't happen in practice but keeps types honest.
|
|
710
|
+
newNode.data = existingData;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
715
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
716
|
+
|
|
717
|
+
type Inner =
|
|
718
|
+
| { kind: 'ok' }
|
|
719
|
+
| { kind: 'badJson'; message: string }
|
|
720
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
721
|
+
| { kind: 'writeFailed'; message: string };
|
|
722
|
+
|
|
723
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
724
|
+
let raw: unknown;
|
|
725
|
+
try {
|
|
726
|
+
raw = await Bun.file(fullPath).json();
|
|
727
|
+
} catch (err) {
|
|
728
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
729
|
+
}
|
|
730
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
731
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
732
|
+
|
|
733
|
+
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
734
|
+
obj.nodes.push(newNode);
|
|
735
|
+
|
|
736
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
737
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
738
|
+
|
|
739
|
+
if (starterFile) {
|
|
740
|
+
try {
|
|
741
|
+
mkdirSync(dirname(starterFile.absPath), { recursive: true });
|
|
742
|
+
writeFileAtomic(starterFile.absPath, starterFile.content);
|
|
743
|
+
} catch (err) {
|
|
744
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
750
|
+
} catch (err) {
|
|
751
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
752
|
+
}
|
|
753
|
+
return { kind: 'ok' };
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// US-015: starter HTML content for studio-created htmlNodes. Centered 'Edit me'
|
|
761
|
+
// card with a `blocks/<id>.html` subtitle — matches the design's Section 6
|
|
762
|
+
// markup exactly so the renderer paints a useful first impression while the
|
|
763
|
+
// author hasn't yet edited the file.
|
|
764
|
+
const buildHtmlNodeStarter = (nodeId: string): string =>
|
|
765
|
+
`<div class="flex h-full w-full items-center justify-center rounded-lg border border-slate-300 bg-white p-4 text-slate-900">
|
|
766
|
+
<div class="text-center">
|
|
767
|
+
<div class="font-semibold">Edit me</div>
|
|
768
|
+
<div class="text-xs text-slate-500">blocks/${nodeId}.html</div>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
`;
|
|
772
|
+
|
|
773
|
+
// Remove a node and cascade-delete every connector touching it in a single
|
|
774
|
+
// atomic write. Final DemoSchema parse stays in place so a pre-existing
|
|
775
|
+
// schema violation surfaces honestly instead of being silently papered over.
|
|
776
|
+
// US-016: when the removed node is an htmlNode whose data.htmlPath matches the
|
|
777
|
+
// studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
|
|
778
|
+
// the seeflow.json write succeeds. Hand-edited paths are left alone (symmetric
|
|
779
|
+
// with US-015's "client-supplied htmlPath wins, no starter file written").
|
|
780
|
+
export async function deleteNodeImpl(
|
|
781
|
+
deps: OperationsDeps,
|
|
782
|
+
demoId: string,
|
|
783
|
+
nodeId: string,
|
|
784
|
+
): Promise<DeleteNodeOutcome> {
|
|
785
|
+
const entry = deps.registry.getById(demoId);
|
|
786
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
787
|
+
|
|
788
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
789
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
790
|
+
|
|
791
|
+
type Inner =
|
|
792
|
+
| { kind: 'ok'; managedHtmlAbsPath?: string }
|
|
793
|
+
| { kind: 'badJson'; message: string }
|
|
794
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
795
|
+
| { kind: 'unknownNode' }
|
|
796
|
+
| { kind: 'writeFailed'; message: string };
|
|
797
|
+
|
|
798
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
799
|
+
let raw: unknown;
|
|
800
|
+
try {
|
|
801
|
+
raw = await Bun.file(fullPath).json();
|
|
802
|
+
} catch (err) {
|
|
803
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
804
|
+
}
|
|
805
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
806
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
807
|
+
|
|
808
|
+
const obj = raw as {
|
|
809
|
+
nodes: Array<Record<string, unknown>>;
|
|
810
|
+
connectors: Array<{ source: string; target: string }>;
|
|
811
|
+
};
|
|
812
|
+
const idx = obj.nodes.findIndex((n) => n.id === nodeId);
|
|
813
|
+
if (idx < 0) return { kind: 'unknownNode' };
|
|
814
|
+
const removed = obj.nodes[idx];
|
|
815
|
+
const managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
|
|
816
|
+
obj.nodes.splice(idx, 1);
|
|
817
|
+
obj.connectors = obj.connectors.filter((cn) => cn.source !== nodeId && cn.target !== nodeId);
|
|
818
|
+
|
|
819
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
820
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
826
|
+
}
|
|
827
|
+
return managedHtmlAbsPath ? { kind: 'ok', managedHtmlAbsPath } : { kind: 'ok' };
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
if (result.kind === 'ok' && result.managedHtmlAbsPath) {
|
|
831
|
+
try {
|
|
832
|
+
unlinkSync(result.managedHtmlAbsPath);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
835
|
+
if (code !== 'ENOENT') {
|
|
836
|
+
console.warn(
|
|
837
|
+
`[operations] failed to remove managed htmlNode file ${result.managedHtmlAbsPath}: ${
|
|
838
|
+
err instanceof Error ? err.message : String(err)
|
|
839
|
+
}`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return { kind: 'ok' };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return result;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// US-016: only delete the companion file when the htmlPath matches the
|
|
850
|
+
// studio-managed shape `blocks/<id>.html` exactly. Hand-edited paths
|
|
851
|
+
// (`custom/hero.html`, an absolute path, anything else) are left alone so
|
|
852
|
+
// authors don't lose work they pointed the node at.
|
|
853
|
+
const managedHtmlNodePath = (
|
|
854
|
+
repoPath: string,
|
|
855
|
+
nodeId: string,
|
|
856
|
+
removed: Record<string, unknown> | undefined,
|
|
857
|
+
): string | undefined => {
|
|
858
|
+
if (!removed || removed.type !== 'htmlNode') return undefined;
|
|
859
|
+
const data = removed.data;
|
|
860
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
|
|
861
|
+
const htmlPath = (data as Record<string, unknown>).htmlPath;
|
|
862
|
+
if (htmlPath !== `blocks/${nodeId}.html`) return undefined;
|
|
863
|
+
return join(repoPath, '.seeflow', htmlPath);
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// Move a single node by writing { x, y } back to its `position` on disk.
|
|
867
|
+
// Mutates the *raw* parsed JSON so any unknown forward-compat fields the
|
|
868
|
+
// schema doesn't yet recognize survive the round-trip untouched.
|
|
869
|
+
export async function moveNodeImpl(
|
|
870
|
+
deps: OperationsDeps,
|
|
871
|
+
demoId: string,
|
|
872
|
+
nodeId: string,
|
|
873
|
+
position: PositionBody,
|
|
874
|
+
): Promise<MoveNodeOutcome> {
|
|
875
|
+
const entry = deps.registry.getById(demoId);
|
|
876
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
877
|
+
|
|
878
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
879
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
880
|
+
|
|
881
|
+
type Inner =
|
|
882
|
+
| { kind: 'ok' }
|
|
883
|
+
| { kind: 'badJson'; message: string }
|
|
884
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
885
|
+
| { kind: 'unknownNode' }
|
|
886
|
+
| { kind: 'writeFailed'; message: string };
|
|
887
|
+
|
|
888
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
889
|
+
let raw: unknown;
|
|
890
|
+
try {
|
|
891
|
+
raw = await Bun.file(fullPath).json();
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
894
|
+
}
|
|
895
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
896
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
897
|
+
|
|
898
|
+
const obj = raw as {
|
|
899
|
+
nodes: Array<{ id: string; position: { x: number; y: number } }>;
|
|
900
|
+
};
|
|
901
|
+
const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
|
|
902
|
+
if (!onDiskNode) return { kind: 'unknownNode' };
|
|
903
|
+
onDiskNode.position = { x: position.x, y: position.y };
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
writeFileAtomic(fullPath, `${JSON.stringify(obj, null, 2)}\n`);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
909
|
+
}
|
|
910
|
+
return { kind: 'ok' };
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
if (result.kind === 'ok') {
|
|
914
|
+
// Eagerly refresh snapshot so a subsequent GET /api/demos/:id (e.g. export)
|
|
915
|
+
// returns the updated position without waiting for the 100ms FSWatcher debounce.
|
|
916
|
+
deps.watcher?.reparse(demoId);
|
|
917
|
+
return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
|
|
918
|
+
}
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Apply a partial PATCH body to a single node. Mutation runs against the
|
|
923
|
+
// raw parsed JSON (so unknown forward-compat fields survive a round-trip),
|
|
924
|
+
// and the whole demo is re-validated through DemoSchema before commit so
|
|
925
|
+
// partial writes can't break invariants like the connector→node superRefine.
|
|
926
|
+
export async function patchNodeImpl(
|
|
927
|
+
deps: OperationsDeps,
|
|
928
|
+
demoId: string,
|
|
929
|
+
nodeId: string,
|
|
930
|
+
updates: NodePatchBody,
|
|
931
|
+
): Promise<PatchNodeOutcome> {
|
|
932
|
+
const entry = deps.registry.getById(demoId);
|
|
933
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
934
|
+
|
|
935
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
936
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
937
|
+
|
|
938
|
+
type Inner =
|
|
939
|
+
| { kind: 'ok' }
|
|
940
|
+
| { kind: 'badJson'; message: string }
|
|
941
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
942
|
+
| { kind: 'unknownNode' }
|
|
943
|
+
| { kind: 'writeFailed'; message: string };
|
|
944
|
+
|
|
945
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
946
|
+
let raw: unknown;
|
|
947
|
+
try {
|
|
948
|
+
raw = await Bun.file(fullPath).json();
|
|
949
|
+
} catch (err) {
|
|
950
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
951
|
+
}
|
|
952
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
953
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
954
|
+
|
|
955
|
+
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
956
|
+
const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
|
|
957
|
+
if (!onDiskNode) return { kind: 'unknownNode' };
|
|
958
|
+
|
|
959
|
+
mergeNodeUpdates(onDiskNode, updates);
|
|
960
|
+
|
|
961
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
962
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
966
|
+
} catch (err) {
|
|
967
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
968
|
+
}
|
|
969
|
+
return { kind: 'ok' };
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Reorder a node within demo.nodes[] (changes paint order in the canvas).
|
|
976
|
+
// A no-op reorder (e.g. forward on the topmost node) returns ok without
|
|
977
|
+
// writing so we don't trigger a watcher echo for nothing.
|
|
978
|
+
export async function reorderNodeImpl(
|
|
979
|
+
deps: OperationsDeps,
|
|
980
|
+
demoId: string,
|
|
981
|
+
nodeId: string,
|
|
982
|
+
body: ReorderBody,
|
|
983
|
+
): Promise<ReorderNodeOutcome> {
|
|
984
|
+
const entry = deps.registry.getById(demoId);
|
|
985
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
986
|
+
|
|
987
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
988
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
989
|
+
|
|
990
|
+
type Inner =
|
|
991
|
+
| { kind: 'ok' }
|
|
992
|
+
| { kind: 'badJson'; message: string }
|
|
993
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
994
|
+
| { kind: 'unknownNode' }
|
|
995
|
+
| { kind: 'writeFailed'; message: string };
|
|
996
|
+
|
|
997
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
998
|
+
let raw: unknown;
|
|
999
|
+
try {
|
|
1000
|
+
raw = await Bun.file(fullPath).json();
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1003
|
+
}
|
|
1004
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
1005
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1006
|
+
|
|
1007
|
+
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
1008
|
+
const fromIdx = obj.nodes.findIndex((n) => n.id === nodeId);
|
|
1009
|
+
if (fromIdx < 0) return { kind: 'unknownNode' };
|
|
1010
|
+
|
|
1011
|
+
const moved = reorderNodes(obj.nodes, fromIdx, body);
|
|
1012
|
+
if (!moved) return { kind: 'ok' };
|
|
1013
|
+
|
|
1014
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
1015
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1021
|
+
}
|
|
1022
|
+
return { kind: 'ok' };
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
return result;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Append a new connector to demo.connectors. `id` is auto-generated when
|
|
1029
|
+
// absent and `kind` defaults to 'default' (the no-semantics user-drawn
|
|
1030
|
+
// variant). Source/target referential integrity is enforced by DemoSchema's
|
|
1031
|
+
// superRefine on the post-mutation parse.
|
|
1032
|
+
export async function addConnectorImpl(
|
|
1033
|
+
deps: OperationsDeps,
|
|
1034
|
+
demoId: string,
|
|
1035
|
+
connBody: Record<string, unknown>,
|
|
1036
|
+
): Promise<AddConnectorOutcome> {
|
|
1037
|
+
const entry = deps.registry.getById(demoId);
|
|
1038
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
1039
|
+
|
|
1040
|
+
const newConn = { ...connBody };
|
|
1041
|
+
if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
|
|
1042
|
+
newConn.id = `conn-${crypto.randomUUID()}`;
|
|
1043
|
+
}
|
|
1044
|
+
if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
|
|
1045
|
+
newConn.kind = 'default';
|
|
1046
|
+
}
|
|
1047
|
+
const newId = newConn.id as string;
|
|
1048
|
+
|
|
1049
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
1050
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1051
|
+
|
|
1052
|
+
type Inner =
|
|
1053
|
+
| { kind: 'ok' }
|
|
1054
|
+
| { kind: 'badJson'; message: string }
|
|
1055
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
1056
|
+
| { kind: 'writeFailed'; message: string };
|
|
1057
|
+
|
|
1058
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
1059
|
+
let raw: unknown;
|
|
1060
|
+
try {
|
|
1061
|
+
raw = await Bun.file(fullPath).json();
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1064
|
+
}
|
|
1065
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
1066
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1067
|
+
|
|
1068
|
+
const obj = raw as { connectors: Array<Record<string, unknown>> };
|
|
1069
|
+
obj.connectors.push(newConn);
|
|
1070
|
+
|
|
1071
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
1072
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1078
|
+
}
|
|
1079
|
+
return { kind: 'ok' };
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
|
|
1083
|
+
return result;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Apply a partial PATCH body to a single connector. Mutation runs against
|
|
1087
|
+
// the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
|
|
1088
|
+
// When `kind` changes, the previous kind's payload fields are dropped first
|
|
1089
|
+
// so the connector doesn't carry phantom data; explicit `null` in the patch
|
|
1090
|
+
// clears the field on disk (used by reconnect-to-body to drop a pinned
|
|
1091
|
+
// handle id). The whole demo is re-validated through DemoSchema before
|
|
1092
|
+
// commit so the discriminated union catches missing-required-fields
|
|
1093
|
+
// (e.g. kind='event' without eventName) and the superRefine gates
|
|
1094
|
+
// source/target referential integrity + handle role invariants.
|
|
1095
|
+
export async function patchConnectorImpl(
|
|
1096
|
+
deps: OperationsDeps,
|
|
1097
|
+
demoId: string,
|
|
1098
|
+
connectorId: string,
|
|
1099
|
+
updates: ConnectorPatchBody,
|
|
1100
|
+
): Promise<PatchConnectorOutcome> {
|
|
1101
|
+
const entry = deps.registry.getById(demoId);
|
|
1102
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
1103
|
+
|
|
1104
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
1105
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1106
|
+
|
|
1107
|
+
type Inner =
|
|
1108
|
+
| { kind: 'ok' }
|
|
1109
|
+
| { kind: 'badJson'; message: string }
|
|
1110
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
1111
|
+
| { kind: 'unknownConnector' }
|
|
1112
|
+
| { kind: 'writeFailed'; message: string };
|
|
1113
|
+
|
|
1114
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
1115
|
+
let raw: unknown;
|
|
1116
|
+
try {
|
|
1117
|
+
raw = await Bun.file(fullPath).json();
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1120
|
+
}
|
|
1121
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
1122
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1123
|
+
|
|
1124
|
+
const obj = raw as { connectors: Array<Record<string, unknown>> };
|
|
1125
|
+
const onDiskConn = obj.connectors.find((cn) => cn.id === connectorId);
|
|
1126
|
+
if (!onDiskConn) return { kind: 'unknownConnector' };
|
|
1127
|
+
|
|
1128
|
+
mergeConnectorUpdates(onDiskConn, updates);
|
|
1129
|
+
|
|
1130
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
1131
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1137
|
+
}
|
|
1138
|
+
return { kind: 'ok' };
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
return result;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Remove a connector by id. No cascade — node deletion is what cascades,
|
|
1145
|
+
// not connector deletion. Final DemoSchema parse still runs so a pre-existing
|
|
1146
|
+
// schema violation surfaces honestly instead of being silently papered over.
|
|
1147
|
+
export async function deleteConnectorImpl(
|
|
1148
|
+
deps: OperationsDeps,
|
|
1149
|
+
demoId: string,
|
|
1150
|
+
connectorId: string,
|
|
1151
|
+
): Promise<DeleteConnectorOutcome> {
|
|
1152
|
+
const entry = deps.registry.getById(demoId);
|
|
1153
|
+
if (!entry) return { kind: 'demoNotFound' };
|
|
1154
|
+
|
|
1155
|
+
const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
|
|
1156
|
+
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1157
|
+
|
|
1158
|
+
type Inner =
|
|
1159
|
+
| { kind: 'ok' }
|
|
1160
|
+
| { kind: 'badJson'; message: string }
|
|
1161
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
1162
|
+
| { kind: 'unknownConnector' }
|
|
1163
|
+
| { kind: 'writeFailed'; message: string };
|
|
1164
|
+
|
|
1165
|
+
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
1166
|
+
let raw: unknown;
|
|
1167
|
+
try {
|
|
1168
|
+
raw = await Bun.file(fullPath).json();
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1171
|
+
}
|
|
1172
|
+
const demoParsed = DemoSchema.safeParse(raw);
|
|
1173
|
+
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1174
|
+
|
|
1175
|
+
const obj = raw as { connectors: Array<{ id: string }> };
|
|
1176
|
+
const idx = obj.connectors.findIndex((cn) => cn.id === connectorId);
|
|
1177
|
+
if (idx < 0) return { kind: 'unknownConnector' };
|
|
1178
|
+
obj.connectors.splice(idx, 1);
|
|
1179
|
+
|
|
1180
|
+
const finalParse = DemoSchema.safeParse(raw);
|
|
1181
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1182
|
+
|
|
1183
|
+
try {
|
|
1184
|
+
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1187
|
+
}
|
|
1188
|
+
return { kind: 'ok' };
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
return result;
|
|
1192
|
+
}
|