framer-code-link 0.1.4 → 0.2.1

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