framer-code-link 0.1.0
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 +196 -0
- package/dist/index.js +2021 -0
- package/dist/project-DhpsFg77.js +53 -0
- package/package.json +36 -0
- package/src/controller.test.ts +966 -0
- package/src/controller.ts +1212 -0
- package/src/helpers/connection.ts +95 -0
- package/src/helpers/files.test.ts +117 -0
- package/src/helpers/files.ts +378 -0
- package/src/helpers/installer.ts +534 -0
- package/src/helpers/sync-validator.ts +87 -0
- package/src/helpers/user-actions.ts +162 -0
- package/src/helpers/watcher.ts +115 -0
- package/src/index.ts +75 -0
- package/src/types.ts +107 -0
- package/src/utils/file-metadata-cache.ts +121 -0
- package/src/utils/hashing.ts +95 -0
- package/src/utils/imports.ts +62 -0
- package/src/utils/logging.ts +47 -0
- package/src/utils/paths.ts +76 -0
- package/src/utils/project.ts +94 -0
- package/src/utils/state-persistence.ts +138 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,1212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller
|
|
3
|
+
* Single source of truth for all runtime state and orchestrates the sync lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* Helpers are functions that provide data - they never hold control or callbacks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs/promises"
|
|
9
|
+
import type { WebSocket } from "ws"
|
|
10
|
+
import type {
|
|
11
|
+
Config,
|
|
12
|
+
IncomingMessage,
|
|
13
|
+
OutgoingMessage,
|
|
14
|
+
FileInfo,
|
|
15
|
+
Conflict,
|
|
16
|
+
WatcherEvent,
|
|
17
|
+
ConflictVersionData,
|
|
18
|
+
} from "./types.js"
|
|
19
|
+
import { initConnection, sendMessage } from "./helpers/connection.js"
|
|
20
|
+
import { initWatcher } from "./helpers/watcher.js"
|
|
21
|
+
import {
|
|
22
|
+
listFiles,
|
|
23
|
+
detectConflicts,
|
|
24
|
+
writeRemoteFiles,
|
|
25
|
+
deleteLocalFile,
|
|
26
|
+
readFileSafe,
|
|
27
|
+
autoResolveConflicts,
|
|
28
|
+
} from "./helpers/files.js"
|
|
29
|
+
import { Installer } from "./helpers/installer.js"
|
|
30
|
+
import { createHashTracker } from "./utils/hashing.js"
|
|
31
|
+
import { info, warn, error, success, debug } from "./utils/logging.js"
|
|
32
|
+
import { hashFileContent } from "./utils/state-persistence.js"
|
|
33
|
+
import {
|
|
34
|
+
FileMetadataCache,
|
|
35
|
+
type FileSyncMetadata,
|
|
36
|
+
} from "./utils/file-metadata-cache.js"
|
|
37
|
+
import { UserActionCoordinator } from "./helpers/user-actions.js"
|
|
38
|
+
import { validateIncomingChange } from "./helpers/sync-validator.js"
|
|
39
|
+
import { findOrCreateProjectDir } from "./utils/project.js"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Explicit sync lifecycle modes
|
|
43
|
+
*/
|
|
44
|
+
export type SyncMode =
|
|
45
|
+
| "disconnected"
|
|
46
|
+
| "handshaking"
|
|
47
|
+
| "snapshot_processing"
|
|
48
|
+
| "conflict_resolution"
|
|
49
|
+
| "watching"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pending operation for echo suppression and replay
|
|
53
|
+
*/
|
|
54
|
+
type PendingOperation =
|
|
55
|
+
| { id: string; type: "write"; file: string; hash: string }
|
|
56
|
+
| { id: string; type: "delete"; file: string; previousHash?: string }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared state that persists across all lifecycle modes
|
|
60
|
+
*/
|
|
61
|
+
interface SyncStateBase {
|
|
62
|
+
queuedDiffs: FileInfo[]
|
|
63
|
+
pendingOperations: Map<string, PendingOperation>
|
|
64
|
+
nextOperationId: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type DisconnectedState = SyncStateBase & {
|
|
68
|
+
mode: "disconnected"
|
|
69
|
+
socket: null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type HandshakingState = SyncStateBase & {
|
|
73
|
+
mode: "handshaking"
|
|
74
|
+
socket: WebSocket
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type SnapshotProcessingState = SyncStateBase & {
|
|
78
|
+
mode: "snapshot_processing"
|
|
79
|
+
socket: WebSocket
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ConflictResolutionState = SyncStateBase & {
|
|
83
|
+
mode: "conflict_resolution"
|
|
84
|
+
socket: WebSocket
|
|
85
|
+
pendingConflicts: Conflict[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type WatchingState = SyncStateBase & {
|
|
89
|
+
mode: "watching"
|
|
90
|
+
socket: WebSocket
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type SyncState =
|
|
94
|
+
| DisconnectedState
|
|
95
|
+
| HandshakingState
|
|
96
|
+
| SnapshotProcessingState
|
|
97
|
+
| ConflictResolutionState
|
|
98
|
+
| WatchingState
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Events that drive state transitions
|
|
102
|
+
*/
|
|
103
|
+
type SyncEvent =
|
|
104
|
+
| {
|
|
105
|
+
type: "HANDSHAKE"
|
|
106
|
+
socket: WebSocket
|
|
107
|
+
projectInfo: { projectId: string; projectName: string }
|
|
108
|
+
}
|
|
109
|
+
| { type: "REQUEST_FILES" }
|
|
110
|
+
| { type: "FILE_LIST"; files: FileInfo[] }
|
|
111
|
+
| {
|
|
112
|
+
type: "CONFLICTS_DETECTED"
|
|
113
|
+
conflicts: Conflict[]
|
|
114
|
+
safeWrites: FileInfo[]
|
|
115
|
+
localOnly: FileInfo[]
|
|
116
|
+
}
|
|
117
|
+
| { type: "FILE_CHANGE"; file: FileInfo; fileMeta?: FileSyncMetadata }
|
|
118
|
+
| { type: "REMOTE_FILE_DELETE"; fileName: string }
|
|
119
|
+
| { type: "REMOTE_DELETE_CONFIRMED"; fileName: string }
|
|
120
|
+
| { type: "REMOTE_DELETE_CANCELLED"; fileName: string; content: string }
|
|
121
|
+
| {
|
|
122
|
+
type: "CONFLICTS_RESOLVED"
|
|
123
|
+
resolution: "local" | "remote"
|
|
124
|
+
}
|
|
125
|
+
| {
|
|
126
|
+
type: "FILE_SYNCED"
|
|
127
|
+
fileName: string
|
|
128
|
+
remoteModifiedAt: number
|
|
129
|
+
}
|
|
130
|
+
| { type: "DISCONNECT" }
|
|
131
|
+
| { type: "WATCHER_EVENT"; event: WatcherEvent }
|
|
132
|
+
| {
|
|
133
|
+
type: "CONFLICT_VERSION_RESPONSE"
|
|
134
|
+
versions: ConflictVersionData[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Side effects emitted by transitions
|
|
139
|
+
*/
|
|
140
|
+
type Effect =
|
|
141
|
+
| {
|
|
142
|
+
type: "INIT_WORKSPACE"
|
|
143
|
+
projectInfo: { projectId: string; projectName: string }
|
|
144
|
+
}
|
|
145
|
+
| { type: "LOAD_PERSISTED_STATE" }
|
|
146
|
+
| { type: "SEND_MESSAGE"; payload: OutgoingMessage }
|
|
147
|
+
| { type: "LIST_LOCAL_FILES" }
|
|
148
|
+
| { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
|
|
149
|
+
| { type: "WRITE_FILES"; files: FileInfo[] }
|
|
150
|
+
| { type: "DELETE_LOCAL_FILES"; names: string[] }
|
|
151
|
+
| { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
|
|
152
|
+
| { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
|
|
153
|
+
| {
|
|
154
|
+
type: "REQUEST_DELETE_CONFIRMATION"
|
|
155
|
+
fileName: string
|
|
156
|
+
requireConfirmation: boolean
|
|
157
|
+
}
|
|
158
|
+
| {
|
|
159
|
+
type: "UPDATE_FILE_METADATA"
|
|
160
|
+
fileName: string
|
|
161
|
+
remoteModifiedAt: number
|
|
162
|
+
}
|
|
163
|
+
| {
|
|
164
|
+
type: "SEND_LOCAL_CHANGE"
|
|
165
|
+
fileName: string
|
|
166
|
+
content: string
|
|
167
|
+
}
|
|
168
|
+
| {
|
|
169
|
+
type: "REQUEST_LOCAL_DELETE_DECISION"
|
|
170
|
+
fileName: string
|
|
171
|
+
requireConfirmation: boolean
|
|
172
|
+
}
|
|
173
|
+
| { type: "PERSIST_STATE" }
|
|
174
|
+
| { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
|
|
175
|
+
|
|
176
|
+
/** One-liner log effect builder */
|
|
177
|
+
function log(level: "info" | "debug" | "warn", message: string): Effect {
|
|
178
|
+
return { type: "LOG", level, message }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Pure state transition function
|
|
183
|
+
* Takes current state + event, returns new state + effects to execute
|
|
184
|
+
*/
|
|
185
|
+
function transition(
|
|
186
|
+
state: SyncState,
|
|
187
|
+
event: SyncEvent
|
|
188
|
+
): { state: SyncState; effects: Effect[] } {
|
|
189
|
+
const effects: Effect[] = []
|
|
190
|
+
|
|
191
|
+
switch (event.type) {
|
|
192
|
+
case "HANDSHAKE": {
|
|
193
|
+
if (state.mode !== "disconnected") {
|
|
194
|
+
effects.push(
|
|
195
|
+
log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`)
|
|
196
|
+
)
|
|
197
|
+
return { state, effects }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
effects.push(
|
|
201
|
+
{ type: "INIT_WORKSPACE", projectInfo: event.projectInfo },
|
|
202
|
+
{ type: "LOAD_PERSISTED_STATE" },
|
|
203
|
+
{ type: "SEND_MESSAGE", payload: { type: "request-files" } }
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
state: {
|
|
208
|
+
...state,
|
|
209
|
+
mode: "handshaking",
|
|
210
|
+
socket: event.socket,
|
|
211
|
+
},
|
|
212
|
+
effects,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "FILE_SYNCED": {
|
|
217
|
+
// Remote confirms they received our local change
|
|
218
|
+
effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
|
|
219
|
+
type: "UPDATE_FILE_METADATA",
|
|
220
|
+
fileName: event.fileName,
|
|
221
|
+
remoteModifiedAt: event.remoteModifiedAt,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
return { state, effects }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case "DISCONNECT": {
|
|
228
|
+
effects.push(
|
|
229
|
+
{ type: "PERSIST_STATE" },
|
|
230
|
+
log("info", "Disconnected, persisting state")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if (state.mode === "conflict_resolution") {
|
|
234
|
+
const { pendingConflicts: _discarded, ...rest } = state
|
|
235
|
+
return {
|
|
236
|
+
state: {
|
|
237
|
+
...rest,
|
|
238
|
+
mode: "disconnected",
|
|
239
|
+
socket: null,
|
|
240
|
+
},
|
|
241
|
+
effects,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
state: {
|
|
247
|
+
...state,
|
|
248
|
+
mode: "disconnected",
|
|
249
|
+
socket: null,
|
|
250
|
+
},
|
|
251
|
+
effects,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case "REQUEST_FILES": {
|
|
256
|
+
// Plugin is asking for our local file list
|
|
257
|
+
// Valid in any mode except disconnected
|
|
258
|
+
if (state.mode === "disconnected") {
|
|
259
|
+
effects.push(
|
|
260
|
+
log("warn", "Received REQUEST_FILES while disconnected, ignoring")
|
|
261
|
+
)
|
|
262
|
+
return { state, effects }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
effects.push(log("info", "Plugin requested file list"), {
|
|
266
|
+
type: "LIST_LOCAL_FILES",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return { state, effects }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "FILE_LIST": {
|
|
273
|
+
if (state.mode !== "handshaking") {
|
|
274
|
+
effects.push(
|
|
275
|
+
log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`)
|
|
276
|
+
)
|
|
277
|
+
return { state, effects }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
effects.push(
|
|
281
|
+
log("info", `Received file list: ${event.files.length} files`)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
// Detect conflicts between remote snapshot and local files
|
|
285
|
+
effects.push({
|
|
286
|
+
type: "DETECT_CONFLICTS",
|
|
287
|
+
remoteFiles: event.files,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Transition to snapshot_processing - conflict detection effect will determine next mode
|
|
291
|
+
return {
|
|
292
|
+
state: {
|
|
293
|
+
...state,
|
|
294
|
+
mode: "snapshot_processing",
|
|
295
|
+
queuedDiffs: event.files,
|
|
296
|
+
},
|
|
297
|
+
effects,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case "CONFLICTS_DETECTED": {
|
|
302
|
+
if (state.mode !== "snapshot_processing") {
|
|
303
|
+
effects.push(
|
|
304
|
+
log(
|
|
305
|
+
"warn",
|
|
306
|
+
`Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return { state, effects }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { conflicts, safeWrites, localOnly } = event
|
|
313
|
+
|
|
314
|
+
// detectConflicts already did auto-resolution:
|
|
315
|
+
// - safeWrites = files we can apply (remote-only or local unchanged)
|
|
316
|
+
// - conflicts = files that need manual resolution (both sides changed)
|
|
317
|
+
// - localOnly = files to upload
|
|
318
|
+
|
|
319
|
+
// Apply safe writes
|
|
320
|
+
if (safeWrites.length > 0) {
|
|
321
|
+
effects.push(log("info", `Applying ${safeWrites.length} safe writes`), {
|
|
322
|
+
type: "WRITE_FILES",
|
|
323
|
+
files: safeWrites,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Upload local-only files
|
|
328
|
+
if (localOnly.length > 0) {
|
|
329
|
+
effects.push(
|
|
330
|
+
log("info", `Uploading ${localOnly.length} local-only files`)
|
|
331
|
+
)
|
|
332
|
+
for (const file of localOnly) {
|
|
333
|
+
effects.push({
|
|
334
|
+
type: "SEND_MESSAGE",
|
|
335
|
+
payload: {
|
|
336
|
+
type: "file-change",
|
|
337
|
+
fileName: file.name,
|
|
338
|
+
content: file.content,
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// If potential conflicts remain, request remote version data before surfacing to user
|
|
345
|
+
if (conflicts.length > 0) {
|
|
346
|
+
effects.push(
|
|
347
|
+
log(
|
|
348
|
+
"warn",
|
|
349
|
+
`${conflicts.length} conflicts require version verification`
|
|
350
|
+
),
|
|
351
|
+
log(
|
|
352
|
+
"info",
|
|
353
|
+
"[CONFLICTS] Requesting remote version data from plugin..."
|
|
354
|
+
),
|
|
355
|
+
{ type: "REQUEST_CONFLICT_VERSIONS", conflicts }
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
state: {
|
|
360
|
+
...state,
|
|
361
|
+
mode: "conflict_resolution",
|
|
362
|
+
pendingConflicts: conflicts,
|
|
363
|
+
},
|
|
364
|
+
effects,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// No conflicts - transition to watching
|
|
369
|
+
effects.push(log("info", "Initial sync complete, entering watch mode"), {
|
|
370
|
+
type: "PERSIST_STATE",
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
state: {
|
|
375
|
+
...state,
|
|
376
|
+
mode: "watching",
|
|
377
|
+
queuedDiffs: [],
|
|
378
|
+
},
|
|
379
|
+
effects,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
case "FILE_CHANGE": {
|
|
384
|
+
// Use helper to validate the incoming change
|
|
385
|
+
const validation = validateIncomingChange(
|
|
386
|
+
event.file,
|
|
387
|
+
event.fileMeta,
|
|
388
|
+
state.mode
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if (validation.action === "queue") {
|
|
392
|
+
effects.push(
|
|
393
|
+
log(
|
|
394
|
+
"debug",
|
|
395
|
+
`Queueing file change: ${event.file.name} (${validation.reason})`
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
state: {
|
|
401
|
+
...state,
|
|
402
|
+
queuedDiffs: [...state.queuedDiffs, event.file],
|
|
403
|
+
},
|
|
404
|
+
effects,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (validation.action === "reject") {
|
|
409
|
+
effects.push(
|
|
410
|
+
log(
|
|
411
|
+
"warn",
|
|
412
|
+
`Rejected file change: ${event.file.name} (${validation.reason})`
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
return { state, effects }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Apply the change
|
|
419
|
+
effects.push(log("info", `Applying remote change: ${event.file.name}`), {
|
|
420
|
+
type: "WRITE_FILES",
|
|
421
|
+
files: [event.file],
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
return { state, effects }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
case "REMOTE_FILE_DELETE": {
|
|
428
|
+
// Reject if not connected
|
|
429
|
+
if (state.mode === "disconnected") {
|
|
430
|
+
effects.push(
|
|
431
|
+
log("warn", `Rejected delete while disconnected: ${event.fileName}`)
|
|
432
|
+
)
|
|
433
|
+
return { state, effects }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Remote deletes should always be applied immediately
|
|
437
|
+
// (the file is already gone from Framer)
|
|
438
|
+
effects.push(
|
|
439
|
+
log("info", `Remote delete applied: ${event.fileName}`),
|
|
440
|
+
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
441
|
+
{ type: "PERSIST_STATE" }
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return { state, effects }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case "REMOTE_DELETE_CONFIRMED": {
|
|
448
|
+
// User confirmed the delete - apply it
|
|
449
|
+
effects.push(
|
|
450
|
+
log("info", `Delete confirmed: ${event.fileName}`),
|
|
451
|
+
{ type: "DELETE_LOCAL_FILES", names: [event.fileName] },
|
|
452
|
+
{ type: "PERSIST_STATE" }
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return { state, effects }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case "REMOTE_DELETE_CANCELLED": {
|
|
459
|
+
// User cancelled - restore the file
|
|
460
|
+
effects.push(log("info", `Delete cancelled: ${event.fileName}`))
|
|
461
|
+
effects.push({
|
|
462
|
+
type: "WRITE_FILES",
|
|
463
|
+
files: [
|
|
464
|
+
{
|
|
465
|
+
name: event.fileName,
|
|
466
|
+
content: event.content,
|
|
467
|
+
modifiedAt: Date.now(),
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
return { state, effects }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case "CONFLICTS_RESOLVED": {
|
|
476
|
+
// Only valid in conflict_resolution mode
|
|
477
|
+
if (state.mode !== "conflict_resolution") {
|
|
478
|
+
effects.push(
|
|
479
|
+
log(
|
|
480
|
+
"warn",
|
|
481
|
+
`Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
return { state, effects }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// User picked one resolution for ALL conflicts
|
|
488
|
+
if (event.resolution === "remote") {
|
|
489
|
+
// Apply all remote versions
|
|
490
|
+
const remoteFiles = state.pendingConflicts.map((c) => ({
|
|
491
|
+
name: c.fileName,
|
|
492
|
+
content: c.remoteContent,
|
|
493
|
+
modifiedAt: c.remoteModifiedAt,
|
|
494
|
+
}))
|
|
495
|
+
|
|
496
|
+
effects.push(
|
|
497
|
+
log("info", `Applying ${remoteFiles.length} remote versions`),
|
|
498
|
+
{ type: "WRITE_FILES", files: remoteFiles }
|
|
499
|
+
)
|
|
500
|
+
} else {
|
|
501
|
+
// Send all local versions
|
|
502
|
+
effects.push(
|
|
503
|
+
log(
|
|
504
|
+
"info",
|
|
505
|
+
`Applying ${state.pendingConflicts.length} local versions`
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
for (const conflict of state.pendingConflicts) {
|
|
509
|
+
effects.push({
|
|
510
|
+
type: "SEND_MESSAGE",
|
|
511
|
+
payload: {
|
|
512
|
+
type: "file-change",
|
|
513
|
+
fileName: conflict.fileName,
|
|
514
|
+
content: conflict.localContent,
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// All conflicts resolved - transition to watching
|
|
521
|
+
effects.push(log("info", "All conflicts resolved, entering watch mode"), {
|
|
522
|
+
type: "PERSIST_STATE",
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const { pendingConflicts: _discarded, ...rest } = state
|
|
526
|
+
return {
|
|
527
|
+
state: {
|
|
528
|
+
...rest,
|
|
529
|
+
mode: "watching",
|
|
530
|
+
},
|
|
531
|
+
effects,
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
case "WATCHER_EVENT": {
|
|
536
|
+
// Local file system change detected
|
|
537
|
+
const { kind, relativePath, content } = event.event
|
|
538
|
+
|
|
539
|
+
// Only process changes in watching mode
|
|
540
|
+
if (state.mode !== "watching") {
|
|
541
|
+
effects.push(
|
|
542
|
+
log(
|
|
543
|
+
"debug",
|
|
544
|
+
`Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
return { state, effects }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// No socket - skip (will diff on reconnect)
|
|
551
|
+
if (!state.socket) {
|
|
552
|
+
effects.push(
|
|
553
|
+
log(
|
|
554
|
+
"debug",
|
|
555
|
+
`Ignoring watcher event (disconnected): ${kind} ${relativePath}`
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
return { state, effects }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
switch (kind) {
|
|
562
|
+
case "add":
|
|
563
|
+
case "change": {
|
|
564
|
+
if (content === undefined) {
|
|
565
|
+
effects.push(
|
|
566
|
+
log("warn", `Watcher event missing content: ${relativePath}`)
|
|
567
|
+
)
|
|
568
|
+
return { state, effects }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
effects.push(log("info", `Local change detected: ${relativePath}`))
|
|
572
|
+
effects.push({
|
|
573
|
+
type: "SEND_LOCAL_CHANGE",
|
|
574
|
+
fileName: relativePath,
|
|
575
|
+
content,
|
|
576
|
+
})
|
|
577
|
+
break
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case "delete": {
|
|
581
|
+
effects.push(log("info", `Local delete detected: ${relativePath}`), {
|
|
582
|
+
type: "REQUEST_LOCAL_DELETE_DECISION",
|
|
583
|
+
fileName: relativePath,
|
|
584
|
+
requireConfirmation: true, // Will be overridden by config in effect
|
|
585
|
+
})
|
|
586
|
+
break
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { state, effects }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case "CONFLICT_VERSION_RESPONSE": {
|
|
594
|
+
if (state.mode !== "conflict_resolution") {
|
|
595
|
+
effects.push(
|
|
596
|
+
log(
|
|
597
|
+
"warn",
|
|
598
|
+
`Received CONFLICT_VERSION_RESPONSE in mode ${state.mode}, ignoring`
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
return { state, effects }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } =
|
|
605
|
+
autoResolveConflicts(state.pendingConflicts, event.versions)
|
|
606
|
+
|
|
607
|
+
if (autoResolvedLocal.length > 0) {
|
|
608
|
+
effects.push(
|
|
609
|
+
log(
|
|
610
|
+
"info",
|
|
611
|
+
`[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
for (const conflict of autoResolvedLocal) {
|
|
615
|
+
effects.push({
|
|
616
|
+
type: "SEND_LOCAL_CHANGE",
|
|
617
|
+
fileName: conflict.fileName,
|
|
618
|
+
content: conflict.localContent,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (autoResolvedRemote.length > 0) {
|
|
624
|
+
effects.push(
|
|
625
|
+
log(
|
|
626
|
+
"info",
|
|
627
|
+
`[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`
|
|
628
|
+
),
|
|
629
|
+
{
|
|
630
|
+
type: "WRITE_FILES",
|
|
631
|
+
files: autoResolvedRemote.map((conflict) => ({
|
|
632
|
+
name: conflict.fileName,
|
|
633
|
+
content: conflict.remoteContent,
|
|
634
|
+
modifiedAt: conflict.remoteModifiedAt ?? Date.now(),
|
|
635
|
+
})),
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (remainingConflicts.length > 0) {
|
|
641
|
+
effects.push(
|
|
642
|
+
log(
|
|
643
|
+
"warn",
|
|
644
|
+
`[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`
|
|
645
|
+
),
|
|
646
|
+
{ type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts }
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
state: {
|
|
651
|
+
...state,
|
|
652
|
+
pendingConflicts: remainingConflicts,
|
|
653
|
+
},
|
|
654
|
+
effects,
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), {
|
|
659
|
+
type: "PERSIST_STATE",
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
const { pendingConflicts: _discarded, ...rest } = state
|
|
663
|
+
return {
|
|
664
|
+
state: {
|
|
665
|
+
...rest,
|
|
666
|
+
mode: "watching",
|
|
667
|
+
queuedDiffs: [],
|
|
668
|
+
},
|
|
669
|
+
effects,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
default: {
|
|
674
|
+
effects.push(log("warn", `Unhandled event type in transition`))
|
|
675
|
+
return { state, effects }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Effect executor - interprets effects and calls helpers
|
|
682
|
+
* Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
|
|
683
|
+
*/
|
|
684
|
+
async function executeEffect(
|
|
685
|
+
effect: Effect,
|
|
686
|
+
context: {
|
|
687
|
+
config: Config
|
|
688
|
+
hashTracker: ReturnType<typeof createHashTracker>
|
|
689
|
+
installer: Installer | null
|
|
690
|
+
fileMetadataCache: FileMetadataCache
|
|
691
|
+
userActions: UserActionCoordinator
|
|
692
|
+
syncState: SyncState
|
|
693
|
+
}
|
|
694
|
+
): Promise<SyncEvent[]> {
|
|
695
|
+
const {
|
|
696
|
+
config,
|
|
697
|
+
hashTracker,
|
|
698
|
+
installer,
|
|
699
|
+
fileMetadataCache,
|
|
700
|
+
userActions,
|
|
701
|
+
syncState,
|
|
702
|
+
} = context
|
|
703
|
+
|
|
704
|
+
switch (effect.type) {
|
|
705
|
+
case "INIT_WORKSPACE": {
|
|
706
|
+
// Initialize project directory if not already set
|
|
707
|
+
if (!config.projectDir) {
|
|
708
|
+
const projectName =
|
|
709
|
+
config.explicitName ?? effect.projectInfo.projectName
|
|
710
|
+
config.projectDir = await findOrCreateProjectDir(
|
|
711
|
+
config.projectHash,
|
|
712
|
+
projectName,
|
|
713
|
+
config.explicitDir
|
|
714
|
+
)
|
|
715
|
+
config.filesDir = `${config.projectDir}/files`
|
|
716
|
+
info(`Files directory: ${config.filesDir}`)
|
|
717
|
+
|
|
718
|
+
// Create files directory
|
|
719
|
+
await fs.mkdir(config.filesDir, { recursive: true })
|
|
720
|
+
}
|
|
721
|
+
return []
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
case "LOAD_PERSISTED_STATE": {
|
|
725
|
+
if (config.projectDir) {
|
|
726
|
+
await fileMetadataCache.initialize(config.projectDir)
|
|
727
|
+
info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
|
|
728
|
+
}
|
|
729
|
+
return []
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
case "LIST_LOCAL_FILES": {
|
|
733
|
+
if (!config.filesDir) {
|
|
734
|
+
return []
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// List all local files and send to plugin
|
|
738
|
+
const files = await listFiles(config.filesDir)
|
|
739
|
+
|
|
740
|
+
if (syncState.socket) {
|
|
741
|
+
await sendMessage(syncState.socket, {
|
|
742
|
+
type: "file-list",
|
|
743
|
+
files,
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return []
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
case "DETECT_CONFLICTS": {
|
|
751
|
+
if (!config.filesDir) {
|
|
752
|
+
return []
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Use existing helper to detect conflicts
|
|
756
|
+
const { conflicts, writes, localOnly } = await detectConflicts(
|
|
757
|
+
effect.remoteFiles,
|
|
758
|
+
config.filesDir,
|
|
759
|
+
{ persistedState: fileMetadataCache.getPersistedState() }
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
// Return CONFLICTS_DETECTED event to continue the flow
|
|
763
|
+
return [
|
|
764
|
+
{
|
|
765
|
+
type: "CONFLICTS_DETECTED",
|
|
766
|
+
conflicts,
|
|
767
|
+
safeWrites: writes,
|
|
768
|
+
localOnly,
|
|
769
|
+
},
|
|
770
|
+
]
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
case "SEND_MESSAGE": {
|
|
774
|
+
if (syncState.socket) {
|
|
775
|
+
await sendMessage(syncState.socket, effect.payload)
|
|
776
|
+
}
|
|
777
|
+
return []
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
case "WRITE_FILES": {
|
|
781
|
+
if (config.filesDir) {
|
|
782
|
+
await writeRemoteFiles(
|
|
783
|
+
effect.files,
|
|
784
|
+
config.filesDir,
|
|
785
|
+
hashTracker,
|
|
786
|
+
installer ?? undefined
|
|
787
|
+
)
|
|
788
|
+
for (const file of effect.files) {
|
|
789
|
+
const remoteTimestamp = file.modifiedAt ?? Date.now()
|
|
790
|
+
fileMetadataCache.recordRemoteWrite(
|
|
791
|
+
file.name,
|
|
792
|
+
file.content,
|
|
793
|
+
remoteTimestamp
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return []
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
case "DELETE_LOCAL_FILES": {
|
|
801
|
+
if (config.filesDir) {
|
|
802
|
+
for (const fileName of effect.names) {
|
|
803
|
+
await deleteLocalFile(fileName, config.filesDir, hashTracker)
|
|
804
|
+
fileMetadataCache.recordDelete(fileName)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return []
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
case "REQUEST_CONFLICT_DECISIONS": {
|
|
811
|
+
await userActions.requestConflictDecisions(
|
|
812
|
+
syncState.socket,
|
|
813
|
+
effect.conflicts
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
return []
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
case "REQUEST_CONFLICT_VERSIONS": {
|
|
820
|
+
if (!syncState.socket) {
|
|
821
|
+
warn("Cannot request conflict versions without active socket")
|
|
822
|
+
return []
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const persistedState = fileMetadataCache.getPersistedState()
|
|
826
|
+
const versionRequests = effect.conflicts.map((conflict) => {
|
|
827
|
+
const persisted = persistedState.get(conflict.fileName)
|
|
828
|
+
return {
|
|
829
|
+
fileName: conflict.fileName,
|
|
830
|
+
lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp,
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
info(
|
|
835
|
+
`[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
await sendMessage(syncState.socket, {
|
|
839
|
+
type: "conflict-version-request",
|
|
840
|
+
conflicts: versionRequests,
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
return []
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
case "REQUEST_DELETE_CONFIRMATION": {
|
|
847
|
+
if (syncState.socket) {
|
|
848
|
+
// Send delete request to plugin
|
|
849
|
+
await sendMessage(syncState.socket, {
|
|
850
|
+
type: "file-delete",
|
|
851
|
+
fileNames: [effect.fileName],
|
|
852
|
+
requireConfirmation: effect.requireConfirmation,
|
|
853
|
+
})
|
|
854
|
+
}
|
|
855
|
+
// Response will come via delete-confirmed or delete-cancelled message
|
|
856
|
+
return []
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
case "UPDATE_FILE_METADATA": {
|
|
860
|
+
if (!config.filesDir || !config.projectDir) {
|
|
861
|
+
return []
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Read current file content to compute hash
|
|
865
|
+
const currentContent = await readFileSafe(
|
|
866
|
+
effect.fileName,
|
|
867
|
+
config.filesDir
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
if (currentContent !== null) {
|
|
871
|
+
const contentHash = hashFileContent(currentContent)
|
|
872
|
+
fileMetadataCache.recordSyncedSnapshot(
|
|
873
|
+
effect.fileName,
|
|
874
|
+
contentHash,
|
|
875
|
+
effect.remoteModifiedAt
|
|
876
|
+
)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return []
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case "SEND_LOCAL_CHANGE": {
|
|
883
|
+
// Echo prevention: skip if we just wrote this exact content
|
|
884
|
+
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
885
|
+
return []
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
// Send change to plugin
|
|
890
|
+
if (syncState.socket) {
|
|
891
|
+
await sendMessage(syncState.socket, {
|
|
892
|
+
type: "file-change",
|
|
893
|
+
fileName: effect.fileName,
|
|
894
|
+
content: effect.content,
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Only remember hash after successful send (prevents re-sending on failure)
|
|
899
|
+
hashTracker.remember(effect.fileName, effect.content)
|
|
900
|
+
|
|
901
|
+
// Trigger type installer
|
|
902
|
+
if (installer) {
|
|
903
|
+
installer.process(effect.fileName, effect.content)
|
|
904
|
+
}
|
|
905
|
+
} catch (err) {
|
|
906
|
+
console.warn(
|
|
907
|
+
`Failed to push change for ${effect.fileName}, will re-sync on next diff:`,
|
|
908
|
+
err
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return []
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
case "REQUEST_LOCAL_DELETE_DECISION": {
|
|
916
|
+
// Echo prevention: skip if this is a remote-initiated delete
|
|
917
|
+
const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName)
|
|
918
|
+
|
|
919
|
+
if (shouldSkip) {
|
|
920
|
+
// Clear the delete marker now that we've caught the echo
|
|
921
|
+
hashTracker.clearDelete(effect.fileName)
|
|
922
|
+
return []
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
const shouldDelete = await userActions.requestDeleteDecision(
|
|
927
|
+
syncState.socket,
|
|
928
|
+
{
|
|
929
|
+
fileName: effect.fileName,
|
|
930
|
+
requireConfirmation: !config.dangerouslyAutoDelete,
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
if (shouldDelete) {
|
|
935
|
+
hashTracker.forget(effect.fileName)
|
|
936
|
+
fileMetadataCache.recordDelete(effect.fileName)
|
|
937
|
+
|
|
938
|
+
if (syncState.socket) {
|
|
939
|
+
await sendMessage(syncState.socket, {
|
|
940
|
+
type: "file-delete",
|
|
941
|
+
fileNames: [effect.fileName],
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch (err) {
|
|
946
|
+
console.warn(`Failed to handle deletion for ${effect.fileName}:`, err)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return []
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
case "PERSIST_STATE": {
|
|
953
|
+
await fileMetadataCache.flush()
|
|
954
|
+
return []
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
case "LOG": {
|
|
958
|
+
const logFn =
|
|
959
|
+
effect.level === "info" ? info : effect.level === "warn" ? warn : debug
|
|
960
|
+
logFn(effect.message)
|
|
961
|
+
return []
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Starts the sync controller with the given configuration
|
|
968
|
+
*/
|
|
969
|
+
export async function start(config: Config): Promise<void> {
|
|
970
|
+
info("🚀 Starting Framer Code Link CLI (Next Gen)")
|
|
971
|
+
info(`Project: ${config.projectHash}`)
|
|
972
|
+
info(`Port: ${config.port} (auto-selected from project hash)`)
|
|
973
|
+
|
|
974
|
+
// State Initialization
|
|
975
|
+
|
|
976
|
+
// Note: We defer project directory creation until handshake if not already set
|
|
977
|
+
// This allows us to receive the project name from the plugin
|
|
978
|
+
|
|
979
|
+
const hashTracker = createHashTracker()
|
|
980
|
+
const fileMetadataCache = new FileMetadataCache()
|
|
981
|
+
let installer: Installer | null = null
|
|
982
|
+
|
|
983
|
+
// State machine state
|
|
984
|
+
const syncState: SyncState = {
|
|
985
|
+
mode: "disconnected",
|
|
986
|
+
socket: null,
|
|
987
|
+
queuedDiffs: [],
|
|
988
|
+
pendingOperations: new Map(),
|
|
989
|
+
nextOperationId: 1,
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const userActions = new UserActionCoordinator()
|
|
993
|
+
|
|
994
|
+
// State Machine Execution Helper
|
|
995
|
+
// Process events through state machine and execute effects recursively
|
|
996
|
+
async function processEvent(event: SyncEvent) {
|
|
997
|
+
const result = transition(syncState, event)
|
|
998
|
+
Object.assign(syncState, result.state)
|
|
999
|
+
|
|
1000
|
+
// Execute all effects and process any follow-up events
|
|
1001
|
+
for (const effect of result.effects) {
|
|
1002
|
+
const followUpEvents = await executeEffect(effect, {
|
|
1003
|
+
config,
|
|
1004
|
+
hashTracker,
|
|
1005
|
+
installer,
|
|
1006
|
+
fileMetadataCache,
|
|
1007
|
+
userActions,
|
|
1008
|
+
syncState,
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
// Recursively process follow-up events
|
|
1012
|
+
for (const followUpEvent of followUpEvents) {
|
|
1013
|
+
await processEvent(followUpEvent)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// WebSocket Connection
|
|
1019
|
+
const connection = initConnection(config.port)
|
|
1020
|
+
|
|
1021
|
+
// Handle initial handshake
|
|
1022
|
+
connection.on("handshake", async (client: WebSocket, message) => {
|
|
1023
|
+
info("Received handshake from plugin")
|
|
1024
|
+
info(`Project: ${message.projectName} (${message.projectId})`)
|
|
1025
|
+
|
|
1026
|
+
// Validate project hash
|
|
1027
|
+
if (message.projectId !== config.projectHash) {
|
|
1028
|
+
warn(
|
|
1029
|
+
`Project ID mismatch: expected ${config.projectHash}, got ${message.projectId}`
|
|
1030
|
+
)
|
|
1031
|
+
client.close()
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Process handshake through state machine
|
|
1036
|
+
await processEvent({
|
|
1037
|
+
type: "HANDSHAKE",
|
|
1038
|
+
socket: client,
|
|
1039
|
+
projectInfo: {
|
|
1040
|
+
projectId: message.projectId,
|
|
1041
|
+
projectName: message.projectName,
|
|
1042
|
+
},
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
// Initialize installer if needed
|
|
1046
|
+
if (config.projectDir && !installer) {
|
|
1047
|
+
installer = new Installer({ projectDir: config.projectDir })
|
|
1048
|
+
await installer.initialize()
|
|
1049
|
+
// Start file watcher now that we have a directory
|
|
1050
|
+
startWatcher()
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
success("Handshake successful - connection established")
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
// Message Handler
|
|
1057
|
+
async function handleMessage(message: IncomingMessage) {
|
|
1058
|
+
// Ensure project is initialized before handling messages
|
|
1059
|
+
if (!config.projectDir || !installer) {
|
|
1060
|
+
warn("Received message before handshake completed - ignoring")
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
let event: SyncEvent | null = null
|
|
1065
|
+
|
|
1066
|
+
// Map incoming messages to state machine events
|
|
1067
|
+
switch (message.type) {
|
|
1068
|
+
case "request-files":
|
|
1069
|
+
event = { type: "REQUEST_FILES" }
|
|
1070
|
+
break
|
|
1071
|
+
|
|
1072
|
+
case "file-list":
|
|
1073
|
+
event = { type: "FILE_LIST", files: message.files }
|
|
1074
|
+
break
|
|
1075
|
+
|
|
1076
|
+
case "file-change":
|
|
1077
|
+
event = {
|
|
1078
|
+
type: "FILE_CHANGE",
|
|
1079
|
+
file: {
|
|
1080
|
+
name: message.fileName,
|
|
1081
|
+
content: message.content,
|
|
1082
|
+
modifiedAt: Date.now(),
|
|
1083
|
+
},
|
|
1084
|
+
fileMeta: fileMetadataCache.get(message.fileName),
|
|
1085
|
+
}
|
|
1086
|
+
break
|
|
1087
|
+
|
|
1088
|
+
case "file-delete": {
|
|
1089
|
+
// Remote deletes are always applied immediately (file is already gone from Framer)
|
|
1090
|
+
for (const fileName of message.fileNames) {
|
|
1091
|
+
await processEvent({
|
|
1092
|
+
type: "REMOTE_FILE_DELETE",
|
|
1093
|
+
fileName,
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
case "delete-confirmed": {
|
|
1100
|
+
const unmatched: string[] = []
|
|
1101
|
+
|
|
1102
|
+
for (const fileName of message.fileNames) {
|
|
1103
|
+
const handled = userActions.handleConfirmation(
|
|
1104
|
+
`delete:${fileName}`,
|
|
1105
|
+
true
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
if (!handled) {
|
|
1109
|
+
unmatched.push(fileName)
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
for (const fileName of unmatched) {
|
|
1114
|
+
await processEvent({ type: "REMOTE_DELETE_CONFIRMED", fileName })
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
case "delete-cancelled": {
|
|
1121
|
+
for (const file of message.files) {
|
|
1122
|
+
userActions.handleConfirmation(`delete:${file.fileName}`, false)
|
|
1123
|
+
|
|
1124
|
+
await processEvent({
|
|
1125
|
+
type: "REMOTE_DELETE_CANCELLED",
|
|
1126
|
+
fileName: file.fileName,
|
|
1127
|
+
content: file.content ?? "",
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
case "file-synced":
|
|
1135
|
+
event = {
|
|
1136
|
+
type: "FILE_SYNCED",
|
|
1137
|
+
fileName: message.fileName,
|
|
1138
|
+
remoteModifiedAt: message.remoteModifiedAt,
|
|
1139
|
+
}
|
|
1140
|
+
break
|
|
1141
|
+
|
|
1142
|
+
case "conflicts-resolved":
|
|
1143
|
+
event = {
|
|
1144
|
+
type: "CONFLICTS_RESOLVED",
|
|
1145
|
+
resolution: message.resolution,
|
|
1146
|
+
}
|
|
1147
|
+
break
|
|
1148
|
+
|
|
1149
|
+
case "conflict-version-response":
|
|
1150
|
+
event = {
|
|
1151
|
+
type: "CONFLICT_VERSION_RESPONSE",
|
|
1152
|
+
versions: message.versions,
|
|
1153
|
+
}
|
|
1154
|
+
break
|
|
1155
|
+
|
|
1156
|
+
default:
|
|
1157
|
+
warn(`Unhandled message type: ${message.type}`)
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (event) {
|
|
1162
|
+
await processEvent(event)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
connection.on("message", async (message: IncomingMessage) => {
|
|
1167
|
+
try {
|
|
1168
|
+
await handleMessage(message)
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
error("Error handling message:", err)
|
|
1171
|
+
}
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
connection.on("disconnect", async () => {
|
|
1175
|
+
warn("Plugin disconnected")
|
|
1176
|
+
await processEvent({ type: "DISCONNECT" })
|
|
1177
|
+
userActions.cleanup()
|
|
1178
|
+
info("Will perform full diff on reconnect")
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
// File Watcher Setup
|
|
1182
|
+
// Note: Watcher will be initialized after handshake when filesDir is set
|
|
1183
|
+
|
|
1184
|
+
let watcher: ReturnType<typeof initWatcher> | null = null
|
|
1185
|
+
|
|
1186
|
+
const startWatcher = () => {
|
|
1187
|
+
if (!config.filesDir || watcher) return
|
|
1188
|
+
watcher = initWatcher(config.filesDir)
|
|
1189
|
+
|
|
1190
|
+
watcher.on("change", async (event) => {
|
|
1191
|
+
await processEvent({ type: "WATCHER_EVENT", event })
|
|
1192
|
+
})
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Lifecycle
|
|
1196
|
+
|
|
1197
|
+
info("✓ Controller initialized and ready")
|
|
1198
|
+
info(`Waiting for plugin connection on port ${config.port}...`)
|
|
1199
|
+
|
|
1200
|
+
// Graceful shutdown
|
|
1201
|
+
process.on("SIGINT", async () => {
|
|
1202
|
+
info("\nShutting down gracefully...")
|
|
1203
|
+
if (watcher) {
|
|
1204
|
+
await watcher.close()
|
|
1205
|
+
}
|
|
1206
|
+
connection.close()
|
|
1207
|
+
process.exit(0)
|
|
1208
|
+
})
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Export for testing
|
|
1212
|
+
export { transition }
|