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.
@@ -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 }