@devosurf/vynt 0.1.2
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 +287 -0
- package/bin/vynt +17 -0
- package/package.json +46 -0
- package/src/bridge.ts +858 -0
- package/src/cli.ts +1449 -0
- package/src/state.ts +167 -0
- package/src/switch.ts +338 -0
- package/src/types.ts +48 -0
- package/vite/index.d.ts +11 -0
- package/vite/index.js +140 -0
- package/web/react/VyntToolbarProvider.d.ts +9 -0
- package/web/react/VyntToolbarProvider.js +615 -0
- package/web/react/index.d.ts +1 -0
- package/web/react/index.js +1 -0
- package/web/vynt-toolbar.js +1075 -0
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
2
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
3
|
+
import { readFile } from "node:fs/promises"
|
|
4
|
+
import { dirname, resolve } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { applySelectionsToWorkspace, getLatestSnapshot, rollbackWorkspaceToSnapshot } from "./switch.js"
|
|
7
|
+
import { loadState, saveState } from "./state.js"
|
|
8
|
+
import type { Objective, PreviewProfile, VariantArtifact, VariantStore } from "./types.js"
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = dirname(__filename)
|
|
12
|
+
const TOOLBAR_SCRIPT_PATH = resolve(__dirname, "..", "web", "vynt-toolbar.js")
|
|
13
|
+
|
|
14
|
+
type BridgeEventType =
|
|
15
|
+
| "server.started"
|
|
16
|
+
| "apply.started"
|
|
17
|
+
| "apply.succeeded"
|
|
18
|
+
| "apply.failed"
|
|
19
|
+
| "finalize.started"
|
|
20
|
+
| "finalize.succeeded"
|
|
21
|
+
| "finalize.failed"
|
|
22
|
+
| "rollback.started"
|
|
23
|
+
| "rollback.succeeded"
|
|
24
|
+
| "rollback.failed"
|
|
25
|
+
| "state.changed"
|
|
26
|
+
|
|
27
|
+
interface BridgeEvent {
|
|
28
|
+
id: number
|
|
29
|
+
type: BridgeEventType
|
|
30
|
+
timestamp: string
|
|
31
|
+
payload: Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StartBridgeServerOptions {
|
|
35
|
+
host: string
|
|
36
|
+
port: number
|
|
37
|
+
projectRoot: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BridgeServerHandle {
|
|
41
|
+
close: () => Promise<void>
|
|
42
|
+
host: string
|
|
43
|
+
port: number
|
|
44
|
+
url: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BridgeStatus {
|
|
48
|
+
initialized: boolean
|
|
49
|
+
latestSnapshot: null | {
|
|
50
|
+
baseRef: string
|
|
51
|
+
createdAt: string
|
|
52
|
+
filePath: string
|
|
53
|
+
head: string
|
|
54
|
+
id: string
|
|
55
|
+
selections: Array<{ objectiveId: string; patchFile: string; variantId: string }>
|
|
56
|
+
}
|
|
57
|
+
state: null | {
|
|
58
|
+
activeProfileId: string | null
|
|
59
|
+
baseRef: string
|
|
60
|
+
objectives: Array<{
|
|
61
|
+
activeVariantId: string | null
|
|
62
|
+
id: string
|
|
63
|
+
name: string
|
|
64
|
+
status: string
|
|
65
|
+
variants: Array<{ changedFiles: string[]; id: string; name: string; status: string; updatedAt: string }>
|
|
66
|
+
winnerVariantId: string | null
|
|
67
|
+
}>
|
|
68
|
+
profiles: Array<{
|
|
69
|
+
id: string
|
|
70
|
+
lastAppliedAt: string | null
|
|
71
|
+
name: string
|
|
72
|
+
selections: Record<string, string>
|
|
73
|
+
updatedAt: string
|
|
74
|
+
}>
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findObjective(state: VariantStore, objectiveId: string): Objective {
|
|
79
|
+
const found = state.objectives.find((objective) => objective.id === objectiveId)
|
|
80
|
+
if (!found) {
|
|
81
|
+
throw new Error(`Objective not found: ${objectiveId}`)
|
|
82
|
+
}
|
|
83
|
+
return found
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findVariant(objective: Objective, variantId: string): VariantArtifact {
|
|
87
|
+
const found = objective.variants.find((variant) => variant.id === variantId)
|
|
88
|
+
if (!found) {
|
|
89
|
+
throw new Error(`Variant not found in objective ${objective.id}: ${variantId}`)
|
|
90
|
+
}
|
|
91
|
+
return found
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findVariantAcrossObjectives(
|
|
95
|
+
state: VariantStore,
|
|
96
|
+
variantId: string,
|
|
97
|
+
): { objective: Objective; variant: VariantArtifact } {
|
|
98
|
+
const matches: Array<{ objective: Objective; variant: VariantArtifact }> = []
|
|
99
|
+
|
|
100
|
+
for (const objective of state.objectives) {
|
|
101
|
+
const variant = objective.variants.find((item) => item.id === variantId)
|
|
102
|
+
if (variant) {
|
|
103
|
+
matches.push({ objective, variant })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (matches.length === 0) {
|
|
108
|
+
throw new Error(`Variant not found: ${variantId}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (matches.length > 1) {
|
|
112
|
+
const objectiveIds = matches.map((item) => item.objective.id).join(", ")
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Variant id ${variantId} is ambiguous across objectives (${objectiveIds}). Use objectiveId + variantId.`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return matches[0] as { objective: Objective; variant: VariantArtifact }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findProfile(state: VariantStore, profileId: string): PreviewProfile {
|
|
122
|
+
const found = state.profiles.find((profile) => profile.id === profileId)
|
|
123
|
+
if (!found) {
|
|
124
|
+
throw new Error(`Profile not found: ${profileId}`)
|
|
125
|
+
}
|
|
126
|
+
return found
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function clearActiveSelections(state: VariantStore): VariantStore {
|
|
130
|
+
const now = new Date().toISOString()
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
...state,
|
|
134
|
+
activeProfileId: undefined,
|
|
135
|
+
objectives: state.objectives.map((objectiveItem) => ({
|
|
136
|
+
...objectiveItem,
|
|
137
|
+
activeVariantId: undefined,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
variants: objectiveItem.variants.map((variant) => {
|
|
140
|
+
if (variant.status !== "selected") return variant
|
|
141
|
+
return {
|
|
142
|
+
...variant,
|
|
143
|
+
status: "ready" as const,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
})),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function markVariantSelectedInState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
|
|
152
|
+
const objectiveItem = findObjective(state, objectiveId)
|
|
153
|
+
const now = new Date().toISOString()
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...state,
|
|
157
|
+
activeProfileId: undefined,
|
|
158
|
+
objectives: state.objectives.map((item) => {
|
|
159
|
+
if (item.id !== objectiveId) return item
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...objectiveItem,
|
|
163
|
+
activeVariantId: variantId,
|
|
164
|
+
status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
variants: objectiveItem.variants.map((variant) => {
|
|
167
|
+
if (variant.id === variantId) {
|
|
168
|
+
return {
|
|
169
|
+
...variant,
|
|
170
|
+
status: "selected" as const,
|
|
171
|
+
updatedAt: now,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (variant.status === "selected") {
|
|
176
|
+
return {
|
|
177
|
+
...variant,
|
|
178
|
+
status: "ready" as const,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return variant
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
186
|
+
}),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function finalizeVariantInObjectiveState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
|
|
191
|
+
const objectiveItem = findObjective(state, objectiveId)
|
|
192
|
+
findVariant(objectiveItem, variantId)
|
|
193
|
+
const now = new Date().toISOString()
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
...state,
|
|
197
|
+
activeProfileId: undefined,
|
|
198
|
+
objectives: state.objectives.map((item) => {
|
|
199
|
+
if (item.id !== objectiveId) return item
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
...objectiveItem,
|
|
203
|
+
activeVariantId: variantId,
|
|
204
|
+
winnerVariantId: variantId,
|
|
205
|
+
status: "finalized" as const,
|
|
206
|
+
updatedAt: now,
|
|
207
|
+
variants: objectiveItem.variants.map((variant) => {
|
|
208
|
+
if (variant.id === variantId) {
|
|
209
|
+
return {
|
|
210
|
+
...variant,
|
|
211
|
+
status: "selected" as const,
|
|
212
|
+
updatedAt: now,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
...variant,
|
|
218
|
+
status: "archived" as const,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
}
|
|
221
|
+
}),
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveSingleApplyTarget(
|
|
228
|
+
state: VariantStore,
|
|
229
|
+
variantId: string,
|
|
230
|
+
objectiveId?: string,
|
|
231
|
+
): { objective: Objective; variant: VariantArtifact } {
|
|
232
|
+
if (objectiveId) {
|
|
233
|
+
const objectiveItem = findObjective(state, objectiveId)
|
|
234
|
+
const variantItem = findVariant(objectiveItem, variantId)
|
|
235
|
+
return { objective: objectiveItem, variant: variantItem }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return findVariantAcrossObjectives(state, variantId)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function applyProfileSelectionsInState(state: VariantStore, profileId: string): VariantStore {
|
|
242
|
+
const profileItem = findProfile(state, profileId)
|
|
243
|
+
const selectionEntries = Object.entries(profileItem.selections)
|
|
244
|
+
if (selectionEntries.length === 0) {
|
|
245
|
+
throw new Error(`Profile ${profileId} has no selections`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const resolved = selectionEntries.map(([objectiveId, variantId]) => {
|
|
249
|
+
const objectiveItem = findObjective(state, objectiveId)
|
|
250
|
+
const variant = findVariant(objectiveItem, variantId)
|
|
251
|
+
return {
|
|
252
|
+
objective: objectiveItem,
|
|
253
|
+
variant,
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const fileOwners = new Map<string, string[]>()
|
|
258
|
+
for (const item of resolved) {
|
|
259
|
+
for (const file of item.variant.changedFiles) {
|
|
260
|
+
const owners = fileOwners.get(file) ?? []
|
|
261
|
+
owners.push(`${item.objective.id}:${item.variant.id}`)
|
|
262
|
+
fileOwners.set(file, owners)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const conflicts = Array.from(fileOwners.entries()).filter(([, owners]) => owners.length > 1)
|
|
267
|
+
if (conflicts.length > 0) {
|
|
268
|
+
const conflictText = conflicts
|
|
269
|
+
.map(([file, owners]) => `${file} -> ${owners.join(", ")}`)
|
|
270
|
+
.join("\n")
|
|
271
|
+
throw new Error(`Profile ${profileId} has overlapping file selections:\n${conflictText}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const now = new Date().toISOString()
|
|
275
|
+
const selectionByObjective = new Map(selectionEntries)
|
|
276
|
+
|
|
277
|
+
const nextObjectives = state.objectives.map((objectiveItem) => {
|
|
278
|
+
const selectedVariantId = selectionByObjective.get(objectiveItem.id)
|
|
279
|
+
if (!selectedVariantId) return objectiveItem
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
...objectiveItem,
|
|
283
|
+
activeVariantId: selectedVariantId,
|
|
284
|
+
status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
variants: objectiveItem.variants.map((variant) => {
|
|
287
|
+
if (variant.id === selectedVariantId) {
|
|
288
|
+
return {
|
|
289
|
+
...variant,
|
|
290
|
+
status: "selected" as const,
|
|
291
|
+
updatedAt: now,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (variant.status === "selected") {
|
|
296
|
+
return {
|
|
297
|
+
...variant,
|
|
298
|
+
status: "ready" as const,
|
|
299
|
+
updatedAt: now,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return variant
|
|
304
|
+
}),
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const nextProfiles = state.profiles.map((current) => {
|
|
309
|
+
if (current.id !== profileId) return current
|
|
310
|
+
return {
|
|
311
|
+
...current,
|
|
312
|
+
lastAppliedAt: now,
|
|
313
|
+
updatedAt: now,
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...state,
|
|
319
|
+
activeProfileId: profileId,
|
|
320
|
+
objectives: nextObjectives,
|
|
321
|
+
profiles: nextProfiles,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function collectActiveSelectionChangedFiles(state: VariantStore): string[] {
|
|
326
|
+
const files = new Set<string>()
|
|
327
|
+
|
|
328
|
+
for (const objective of state.objectives) {
|
|
329
|
+
if (!objective.activeVariantId) continue
|
|
330
|
+
const activeVariant = objective.variants.find((variant) => variant.id === objective.activeVariantId)
|
|
331
|
+
if (!activeVariant) continue
|
|
332
|
+
|
|
333
|
+
for (const file of activeVariant.changedFiles) {
|
|
334
|
+
files.add(file)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return [...files]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function applySelectionsWithActiveRollback(
|
|
342
|
+
projectRoot: string,
|
|
343
|
+
state: VariantStore,
|
|
344
|
+
selections: Array<{ objectiveId: string; patchFile: string; variantId: string; changedFiles?: string[] }>,
|
|
345
|
+
): Promise<{ snapshotPath: string }> {
|
|
346
|
+
try {
|
|
347
|
+
return await applySelectionsToWorkspace(projectRoot, state.baseRef, selections)
|
|
348
|
+
} catch (error: unknown) {
|
|
349
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
350
|
+
if (!message.includes("Workspace has uncommitted changes")) {
|
|
351
|
+
throw error
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const allowDirtyFiles = collectActiveSelectionChangedFiles(state)
|
|
355
|
+
if (allowDirtyFiles.length === 0) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`${message}. No active changedFiles available for safe auto-rollback; run rollback first or register variants with changed files.`,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await rollbackWorkspaceToSnapshot(projectRoot, undefined, { allowDirtyFiles })
|
|
362
|
+
return applySelectionsToWorkspace(projectRoot, state.baseRef, selections)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readPort(value: string | undefined, fallback: number): number {
|
|
367
|
+
const candidate = Number(value)
|
|
368
|
+
if (!Number.isInteger(candidate) || candidate < 0 || candidate > 65535) {
|
|
369
|
+
return fallback
|
|
370
|
+
}
|
|
371
|
+
return candidate
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function parseBody<T>(req: IncomingMessage): Promise<T> {
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
let body = ""
|
|
377
|
+
req.on("data", (chunk) => {
|
|
378
|
+
body += chunk
|
|
379
|
+
})
|
|
380
|
+
req.on("end", () => {
|
|
381
|
+
if (!body.trim()) {
|
|
382
|
+
resolve({} as T)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
resolve(JSON.parse(body) as T)
|
|
388
|
+
} catch {
|
|
389
|
+
reject(new Error("Invalid JSON body"))
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
req.on("error", reject)
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
397
|
+
res.writeHead(status, {
|
|
398
|
+
"Access-Control-Allow-Headers": "Content-Type, Accept",
|
|
399
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
400
|
+
"Access-Control-Allow-Origin": "*",
|
|
401
|
+
"Cache-Control": "no-store",
|
|
402
|
+
"Content-Type": "application/json",
|
|
403
|
+
})
|
|
404
|
+
res.end(JSON.stringify(data))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function sendScript(res: ServerResponse, content: string): void {
|
|
408
|
+
res.writeHead(200, {
|
|
409
|
+
"Access-Control-Allow-Origin": "*",
|
|
410
|
+
"Cache-Control": "no-store",
|
|
411
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
412
|
+
})
|
|
413
|
+
res.end(content)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sendCors(res: ServerResponse): void {
|
|
417
|
+
res.writeHead(204, {
|
|
418
|
+
"Access-Control-Allow-Headers": "Content-Type, Accept",
|
|
419
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
420
|
+
"Access-Control-Allow-Origin": "*",
|
|
421
|
+
"Access-Control-Max-Age": "86400",
|
|
422
|
+
})
|
|
423
|
+
res.end()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function summarizeState(state: VariantStore | undefined, latestSnapshot: Awaited<ReturnType<typeof getLatestSnapshot>>): BridgeStatus {
|
|
427
|
+
if (!state) {
|
|
428
|
+
return {
|
|
429
|
+
initialized: false,
|
|
430
|
+
latestSnapshot: null,
|
|
431
|
+
state: null,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
initialized: true,
|
|
437
|
+
latestSnapshot: latestSnapshot
|
|
438
|
+
? {
|
|
439
|
+
baseRef: latestSnapshot.baseRef,
|
|
440
|
+
createdAt: latestSnapshot.createdAt,
|
|
441
|
+
filePath: latestSnapshot.filePath,
|
|
442
|
+
head: latestSnapshot.head,
|
|
443
|
+
id: latestSnapshot.id,
|
|
444
|
+
selections: latestSnapshot.selections,
|
|
445
|
+
}
|
|
446
|
+
: null,
|
|
447
|
+
state: {
|
|
448
|
+
activeProfileId: state.activeProfileId ?? null,
|
|
449
|
+
baseRef: state.baseRef,
|
|
450
|
+
objectives: state.objectives.map((objective) => ({
|
|
451
|
+
activeVariantId: objective.activeVariantId ?? null,
|
|
452
|
+
id: objective.id,
|
|
453
|
+
name: objective.name,
|
|
454
|
+
status: objective.status,
|
|
455
|
+
variants: objective.variants.map((variant) => ({
|
|
456
|
+
changedFiles: variant.changedFiles,
|
|
457
|
+
id: variant.id,
|
|
458
|
+
name: variant.name,
|
|
459
|
+
status: variant.status,
|
|
460
|
+
updatedAt: variant.updatedAt,
|
|
461
|
+
})),
|
|
462
|
+
winnerVariantId: objective.winnerVariantId ?? null,
|
|
463
|
+
})),
|
|
464
|
+
profiles: state.profiles.map((profile) => ({
|
|
465
|
+
id: profile.id,
|
|
466
|
+
lastAppliedAt: profile.lastAppliedAt ?? null,
|
|
467
|
+
name: profile.name,
|
|
468
|
+
selections: profile.selections,
|
|
469
|
+
updatedAt: profile.updatedAt,
|
|
470
|
+
})),
|
|
471
|
+
},
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function sendSseEvent(res: ServerResponse, event: BridgeEvent): void {
|
|
476
|
+
res.write(`id: ${event.id}\n`)
|
|
477
|
+
res.write(`event: ${event.type}\n`)
|
|
478
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function getServerAddress(server: Server): { host: string; port: number; url: string } {
|
|
482
|
+
const address = server.address()
|
|
483
|
+
if (!address || typeof address === "string") {
|
|
484
|
+
throw new Error("Unable to determine bridge server address")
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const host = address.address === "::" ? "127.0.0.1" : address.address
|
|
488
|
+
return {
|
|
489
|
+
host,
|
|
490
|
+
port: address.port,
|
|
491
|
+
url: `http://${host}:${address.port}`,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function startBridgeServer(options: StartBridgeServerOptions): Promise<BridgeServerHandle> {
|
|
496
|
+
const projectRoot = resolve(options.projectRoot)
|
|
497
|
+
const host = options.host || "127.0.0.1"
|
|
498
|
+
const requestedPort = readPort(String(options.port), 4173)
|
|
499
|
+
|
|
500
|
+
const sseClients = new Set<ServerResponse>()
|
|
501
|
+
let sequence = 0
|
|
502
|
+
let stateWatcher: FSWatcher | null = null
|
|
503
|
+
let stateWatchTimer: ReturnType<typeof setTimeout> | null = null
|
|
504
|
+
|
|
505
|
+
const emitEvent = (type: BridgeEventType, payload: Record<string, unknown>) => {
|
|
506
|
+
const event: BridgeEvent = {
|
|
507
|
+
id: ++sequence,
|
|
508
|
+
payload,
|
|
509
|
+
timestamp: new Date().toISOString(),
|
|
510
|
+
type,
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
for (const client of sseClients) {
|
|
514
|
+
sendSseEvent(client, event)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const scheduleExternalStateChangeEvent = () => {
|
|
519
|
+
if (stateWatchTimer) {
|
|
520
|
+
clearTimeout(stateWatchTimer)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
stateWatchTimer = setTimeout(() => {
|
|
524
|
+
emitEvent("state.changed", { reason: "external-state-change" })
|
|
525
|
+
}, 120)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const startStateWatcher = () => {
|
|
529
|
+
const vyntDir = resolve(projectRoot, ".vynt")
|
|
530
|
+
try {
|
|
531
|
+
stateWatcher = watch(vyntDir, { persistent: false }, (_eventType, filename) => {
|
|
532
|
+
const fileName = typeof filename === "string" ? filename : ""
|
|
533
|
+
if (fileName.length > 0 && fileName !== "state.json") {
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
scheduleExternalStateChangeEvent()
|
|
537
|
+
})
|
|
538
|
+
} catch {
|
|
539
|
+
stateWatcher = null
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const server = createServer(async (req, res) => {
|
|
544
|
+
const method = req.method ?? "GET"
|
|
545
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
546
|
+
|
|
547
|
+
if (method === "OPTIONS") {
|
|
548
|
+
sendCors(res)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (method === "GET" && url.pathname === "/events") {
|
|
553
|
+
res.writeHead(200, {
|
|
554
|
+
"Access-Control-Allow-Origin": "*",
|
|
555
|
+
"Cache-Control": "no-cache",
|
|
556
|
+
Connection: "keep-alive",
|
|
557
|
+
"Content-Type": "text/event-stream",
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
res.write(": connected\n\n")
|
|
561
|
+
sseClients.add(res)
|
|
562
|
+
|
|
563
|
+
req.on("close", () => {
|
|
564
|
+
sseClients.delete(res)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (method === "GET" && url.pathname === "/toolbar.js") {
|
|
571
|
+
try {
|
|
572
|
+
const scriptContent = await readFile(TOOLBAR_SCRIPT_PATH, "utf8")
|
|
573
|
+
sendScript(res, scriptContent)
|
|
574
|
+
} catch (error: unknown) {
|
|
575
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
576
|
+
sendJson(res, 500, { error: `Failed to load toolbar script: ${message}` })
|
|
577
|
+
}
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
582
|
+
sendJson(res, 200, {
|
|
583
|
+
projectRoot,
|
|
584
|
+
status: "ok",
|
|
585
|
+
})
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (method === "GET" && url.pathname === "/status") {
|
|
590
|
+
const state = await loadState(projectRoot)
|
|
591
|
+
const latestSnapshot = await getLatestSnapshot(projectRoot)
|
|
592
|
+
sendJson(res, 200, summarizeState(state, latestSnapshot))
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (method === "GET" && url.pathname === "/state") {
|
|
597
|
+
const state = await loadState(projectRoot)
|
|
598
|
+
if (!state) {
|
|
599
|
+
sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
sendJson(res, 200, state)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (method === "POST" && url.pathname === "/apply") {
|
|
607
|
+
try {
|
|
608
|
+
const state = await loadState(projectRoot)
|
|
609
|
+
if (!state) {
|
|
610
|
+
sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const body = await parseBody<{ objectiveId?: string; profileId?: string; variantId?: string }>(req)
|
|
615
|
+
|
|
616
|
+
if (body.profileId && body.variantId) {
|
|
617
|
+
throw new Error("Use either profileId or variantId, not both")
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!body.profileId && !body.variantId) {
|
|
621
|
+
throw new Error("apply requires profileId or variantId")
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
emitEvent("apply.started", {
|
|
625
|
+
mode: body.profileId ? "profile" : "single",
|
|
626
|
+
objectiveId: body.objectiveId,
|
|
627
|
+
profileId: body.profileId,
|
|
628
|
+
variantId: body.variantId,
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
if (body.profileId) {
|
|
632
|
+
const profileItem = findProfile(state, body.profileId)
|
|
633
|
+
const selectionEntries = Object.entries(profileItem.selections)
|
|
634
|
+
if (selectionEntries.length === 0) {
|
|
635
|
+
throw new Error(`Profile ${body.profileId} has no selections`)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const resolved = selectionEntries.map(([objectiveId, variantId]) => {
|
|
639
|
+
const objectiveItem = findObjective(state, objectiveId)
|
|
640
|
+
const variant = findVariant(objectiveItem, variantId)
|
|
641
|
+
return {
|
|
642
|
+
objective: objectiveItem,
|
|
643
|
+
variant,
|
|
644
|
+
}
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
const { snapshotPath } = await applySelectionsWithActiveRollback(
|
|
648
|
+
projectRoot,
|
|
649
|
+
state,
|
|
650
|
+
resolved.map((item) => ({
|
|
651
|
+
objectiveId: item.objective.id,
|
|
652
|
+
patchFile: item.variant.patchFile,
|
|
653
|
+
variantId: item.variant.id,
|
|
654
|
+
changedFiles: item.variant.changedFiles,
|
|
655
|
+
})),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
const nextState = applyProfileSelectionsInState(state, body.profileId)
|
|
659
|
+
await saveState(projectRoot, nextState)
|
|
660
|
+
|
|
661
|
+
emitEvent("apply.succeeded", {
|
|
662
|
+
mode: "profile",
|
|
663
|
+
profileId: body.profileId,
|
|
664
|
+
snapshotPath,
|
|
665
|
+
})
|
|
666
|
+
emitEvent("state.changed", {
|
|
667
|
+
reason: "apply-profile",
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
sendJson(res, 200, {
|
|
671
|
+
mode: "profile",
|
|
672
|
+
profileId: body.profileId,
|
|
673
|
+
snapshotPath,
|
|
674
|
+
})
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const variantId = body.variantId as string
|
|
679
|
+
const target = resolveSingleApplyTarget(state, variantId, body.objectiveId)
|
|
680
|
+
|
|
681
|
+
const { snapshotPath } = await applySelectionsWithActiveRollback(projectRoot, state, [
|
|
682
|
+
{
|
|
683
|
+
objectiveId: target.objective.id,
|
|
684
|
+
patchFile: target.variant.patchFile,
|
|
685
|
+
variantId: target.variant.id,
|
|
686
|
+
changedFiles: target.variant.changedFiles,
|
|
687
|
+
},
|
|
688
|
+
])
|
|
689
|
+
|
|
690
|
+
const nextState = markVariantSelectedInState(state, target.objective.id, target.variant.id)
|
|
691
|
+
await saveState(projectRoot, nextState)
|
|
692
|
+
|
|
693
|
+
emitEvent("apply.succeeded", {
|
|
694
|
+
mode: "single",
|
|
695
|
+
objectiveId: target.objective.id,
|
|
696
|
+
snapshotPath,
|
|
697
|
+
variantId: target.variant.id,
|
|
698
|
+
})
|
|
699
|
+
emitEvent("state.changed", {
|
|
700
|
+
reason: "apply-single",
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
sendJson(res, 200, {
|
|
704
|
+
mode: "single",
|
|
705
|
+
objectiveId: target.objective.id,
|
|
706
|
+
snapshotPath,
|
|
707
|
+
variantId: target.variant.id,
|
|
708
|
+
})
|
|
709
|
+
} catch (error: unknown) {
|
|
710
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
711
|
+
emitEvent("apply.failed", { error: message })
|
|
712
|
+
sendJson(res, 400, { error: message })
|
|
713
|
+
}
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (method === "POST" && url.pathname === "/finalize") {
|
|
718
|
+
try {
|
|
719
|
+
const state = await loadState(projectRoot)
|
|
720
|
+
if (!state) {
|
|
721
|
+
sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const body = await parseBody<{ objectiveId?: string; variantId?: string }>(req)
|
|
726
|
+
if (!body.objectiveId || !body.variantId) {
|
|
727
|
+
throw new Error("finalize requires objectiveId and variantId")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const target = resolveSingleApplyTarget(state, body.variantId, body.objectiveId)
|
|
731
|
+
|
|
732
|
+
emitEvent("finalize.started", {
|
|
733
|
+
objectiveId: target.objective.id,
|
|
734
|
+
variantId: target.variant.id,
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
const { snapshotPath } = await applySelectionsWithActiveRollback(projectRoot, state, [
|
|
738
|
+
{
|
|
739
|
+
objectiveId: target.objective.id,
|
|
740
|
+
patchFile: target.variant.patchFile,
|
|
741
|
+
variantId: target.variant.id,
|
|
742
|
+
changedFiles: target.variant.changedFiles,
|
|
743
|
+
},
|
|
744
|
+
])
|
|
745
|
+
|
|
746
|
+
const nextState = finalizeVariantInObjectiveState(state, target.objective.id, target.variant.id)
|
|
747
|
+
await saveState(projectRoot, nextState)
|
|
748
|
+
|
|
749
|
+
emitEvent("finalize.succeeded", {
|
|
750
|
+
objectiveId: target.objective.id,
|
|
751
|
+
snapshotPath,
|
|
752
|
+
variantId: target.variant.id,
|
|
753
|
+
})
|
|
754
|
+
emitEvent("state.changed", {
|
|
755
|
+
reason: "finalize-objective",
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
sendJson(res, 200, {
|
|
759
|
+
objectiveId: target.objective.id,
|
|
760
|
+
snapshotPath,
|
|
761
|
+
variantId: target.variant.id,
|
|
762
|
+
})
|
|
763
|
+
} catch (error: unknown) {
|
|
764
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
765
|
+
emitEvent("finalize.failed", { error: message })
|
|
766
|
+
sendJson(res, 400, { error: message })
|
|
767
|
+
}
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (method === "POST" && url.pathname === "/rollback") {
|
|
772
|
+
try {
|
|
773
|
+
const state = await loadState(projectRoot)
|
|
774
|
+
if (!state) {
|
|
775
|
+
sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const body = await parseBody<{ snapshotId?: string }>(req)
|
|
780
|
+
emitEvent("rollback.started", { snapshotId: body.snapshotId ?? null })
|
|
781
|
+
|
|
782
|
+
const { snapshot } = await rollbackWorkspaceToSnapshot(projectRoot, body.snapshotId, {
|
|
783
|
+
allowDirtyFiles: collectActiveSelectionChangedFiles(state),
|
|
784
|
+
})
|
|
785
|
+
const nextState = clearActiveSelections(state)
|
|
786
|
+
await saveState(projectRoot, nextState)
|
|
787
|
+
|
|
788
|
+
emitEvent("rollback.succeeded", {
|
|
789
|
+
snapshotHead: snapshot.head,
|
|
790
|
+
snapshotId: snapshot.id,
|
|
791
|
+
})
|
|
792
|
+
emitEvent("state.changed", {
|
|
793
|
+
reason: "rollback",
|
|
794
|
+
snapshotId: snapshot.id,
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
sendJson(res, 200, {
|
|
798
|
+
snapshotHead: snapshot.head,
|
|
799
|
+
snapshotId: snapshot.id,
|
|
800
|
+
})
|
|
801
|
+
} catch (error: unknown) {
|
|
802
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
803
|
+
emitEvent("rollback.failed", { error: message })
|
|
804
|
+
sendJson(res, 400, { error: message })
|
|
805
|
+
}
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
sendJson(res, 404, { error: "Not found" })
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
await new Promise<void>((resolveListen, rejectListen) => {
|
|
813
|
+
server.once("error", rejectListen)
|
|
814
|
+
server.listen(requestedPort, host, () => {
|
|
815
|
+
server.removeListener("error", rejectListen)
|
|
816
|
+
resolveListen()
|
|
817
|
+
})
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
const address = getServerAddress(server)
|
|
821
|
+
startStateWatcher()
|
|
822
|
+
emitEvent("server.started", {
|
|
823
|
+
host: address.host,
|
|
824
|
+
port: address.port,
|
|
825
|
+
projectRoot,
|
|
826
|
+
url: address.url,
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
close: async () => {
|
|
831
|
+
if (stateWatchTimer) {
|
|
832
|
+
clearTimeout(stateWatchTimer)
|
|
833
|
+
stateWatchTimer = null
|
|
834
|
+
}
|
|
835
|
+
if (stateWatcher) {
|
|
836
|
+
stateWatcher.close()
|
|
837
|
+
stateWatcher = null
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
for (const client of sseClients) {
|
|
841
|
+
client.end()
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
await new Promise<void>((resolveClose, rejectClose) => {
|
|
845
|
+
server.close((error) => {
|
|
846
|
+
if (error) {
|
|
847
|
+
rejectClose(error)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
resolveClose()
|
|
851
|
+
})
|
|
852
|
+
})
|
|
853
|
+
},
|
|
854
|
+
host: address.host,
|
|
855
|
+
port: address.port,
|
|
856
|
+
url: address.url,
|
|
857
|
+
}
|
|
858
|
+
}
|