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,95 @@
1
+ /**
2
+ * WebSocket connection helper
3
+ *
4
+ * Thin wrapper around ws.Server that normalizes handshake and surfaces
5
+ * simple callbacks. Keeps raw socket API localized.
6
+ */
7
+
8
+ import { WebSocketServer, WebSocket } from "ws"
9
+ import type { IncomingMessage, OutgoingMessage } from "../types.js"
10
+ import { info, error } from "../utils/logging.js"
11
+
12
+ export interface ConnectionCallbacks {
13
+ onHandshake: (
14
+ client: WebSocket,
15
+ message: { projectId: string; projectName: string }
16
+ ) => void
17
+ onMessage: (message: IncomingMessage) => void
18
+ onDisconnect: () => void
19
+ }
20
+
21
+ export interface Connection {
22
+ on(event: "handshake", handler: ConnectionCallbacks["onHandshake"]): void
23
+ on(event: "message", handler: ConnectionCallbacks["onMessage"]): void
24
+ on(event: "disconnect", handler: ConnectionCallbacks["onDisconnect"]): void
25
+ close(): void
26
+ }
27
+
28
+ /**
29
+ * Initializes a WebSocket server and returns a connection interface
30
+ */
31
+ export function initConnection(port: number): Connection {
32
+ const wss = new WebSocketServer({ port })
33
+ const handlers: Partial<ConnectionCallbacks> = {}
34
+
35
+ info(`WebSocket server listening on port ${port}`)
36
+
37
+ wss.on("connection", (ws: WebSocket) => {
38
+ info("Client connected")
39
+
40
+ ws.on("message", (data: Buffer) => {
41
+ try {
42
+ const message = JSON.parse(data.toString()) as IncomingMessage
43
+
44
+ // Special handling for handshake
45
+ if (message.type === "handshake") {
46
+ handlers.onHandshake?.(ws, message)
47
+ } else {
48
+ handlers.onMessage?.(message)
49
+ }
50
+ } catch (err) {
51
+ error("Failed to parse message:", err)
52
+ }
53
+ })
54
+
55
+ ws.on("close", () => {
56
+ info("Client disconnected")
57
+ handlers.onDisconnect?.()
58
+ })
59
+
60
+ ws.on("error", (err) => {
61
+ error("WebSocket error:", err)
62
+ })
63
+ })
64
+
65
+ return {
66
+ on(event: "handshake" | "message" | "disconnect", handler: any): void {
67
+ if (event === "handshake") {
68
+ handlers.onHandshake = handler
69
+ } else if (event === "message") {
70
+ handlers.onMessage = handler
71
+ } else if (event === "disconnect") {
72
+ handlers.onDisconnect = handler
73
+ }
74
+ },
75
+
76
+ close(): void {
77
+ wss.close()
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Sends a message to a connected socket
84
+ */
85
+ export function sendMessage(
86
+ socket: WebSocket,
87
+ message: OutgoingMessage
88
+ ): Promise<void> {
89
+ return new Promise((resolve, reject) => {
90
+ socket.send(JSON.stringify(message), (err) => {
91
+ if (err) reject(err)
92
+ else resolve()
93
+ })
94
+ })
95
+ }
@@ -0,0 +1,117 @@
1
+ import fs from "fs/promises"
2
+ import os from "os"
3
+ import path from "path"
4
+ import { describe, it, expect } from "vitest"
5
+ import { autoResolveConflicts, detectConflicts } from "./files.js"
6
+ import type { Conflict } from "../types.js"
7
+ import { hashFileContent } from "../utils/state-persistence.js"
8
+
9
+ function makeConflict(overrides: Partial<Conflict> = {}): Conflict {
10
+ return {
11
+ fileName: overrides.fileName ?? "Test.tsx",
12
+ localContent: overrides.localContent ?? "local",
13
+ remoteContent: overrides.remoteContent ?? "remote",
14
+ localModifiedAt: overrides.localModifiedAt ?? Date.now(),
15
+ remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(),
16
+ lastSyncedAt: overrides.lastSyncedAt ?? Date.now(),
17
+ localClean: overrides.localClean,
18
+ }
19
+ }
20
+
21
+ describe("autoResolveConflicts", () => {
22
+ it("classifies conflicts as local when remote unchanged and local changed", () => {
23
+ const conflict = makeConflict({
24
+ lastSyncedAt: 5_000,
25
+ localClean: false,
26
+ })
27
+
28
+ const result = autoResolveConflicts(
29
+ [conflict],
30
+ [{ fileName: conflict.fileName, latestRemoteVersionMs: 5_000 }]
31
+ )
32
+
33
+ expect(result.autoResolvedLocal).toHaveLength(1)
34
+ expect(result.autoResolvedRemote).toHaveLength(0)
35
+ expect(result.remainingConflicts).toHaveLength(0)
36
+ })
37
+
38
+ it("classifies conflicts as remote when local is clean and remote changed", () => {
39
+ const conflict = makeConflict({
40
+ lastSyncedAt: 5_000,
41
+ localClean: true,
42
+ })
43
+
44
+ const result = autoResolveConflicts(
45
+ [conflict],
46
+ [{ fileName: conflict.fileName, latestRemoteVersionMs: 10_000 }]
47
+ )
48
+
49
+ expect(result.autoResolvedRemote).toHaveLength(1)
50
+ expect(result.autoResolvedLocal).toHaveLength(0)
51
+ })
52
+
53
+ it("keeps conflicts that have both sides changed", () => {
54
+ const conflict = makeConflict({
55
+ lastSyncedAt: 5_000,
56
+ localClean: false,
57
+ })
58
+
59
+ const result = autoResolveConflicts(
60
+ [conflict],
61
+ [{ fileName: conflict.fileName, latestRemoteVersionMs: 7_500 }]
62
+ )
63
+
64
+ expect(result.remainingConflicts).toHaveLength(1)
65
+ expect(result.autoResolvedLocal).toHaveLength(0)
66
+ expect(result.autoResolvedRemote).toHaveLength(0)
67
+ })
68
+
69
+ it("keeps conflicts when version data is missing", () => {
70
+ const conflict = makeConflict({
71
+ lastSyncedAt: 5_000,
72
+ localClean: true,
73
+ })
74
+
75
+ const result = autoResolveConflicts([conflict], [])
76
+
77
+ expect(result.remainingConflicts).toHaveLength(1)
78
+ })
79
+ })
80
+
81
+ describe("detectConflicts", () => {
82
+ it("marks conflicts as localClean when local matches persisted state", async () => {
83
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-"))
84
+ try {
85
+ const filesDir = path.join(tmpRoot, "files")
86
+ await fs.mkdir(filesDir, { recursive: true })
87
+
88
+ const localContent = "local content"
89
+ await fs.writeFile(path.join(filesDir, "Test.tsx"), localContent, "utf-8")
90
+
91
+ const persistedState = new Map([
92
+ [
93
+ "Test.tsx",
94
+ { contentHash: hashFileContent(localContent), timestamp: 1_000 },
95
+ ],
96
+ ])
97
+
98
+ const result = await detectConflicts(
99
+ [
100
+ {
101
+ name: "Test.tsx",
102
+ content: "remote content",
103
+ modifiedAt: 2_000,
104
+ },
105
+ ],
106
+ filesDir,
107
+ { persistedState }
108
+ )
109
+
110
+ expect(result.writes).toHaveLength(0)
111
+ expect(result.conflicts).toHaveLength(1)
112
+ expect(result.conflicts[0]?.localClean).toBe(true)
113
+ } finally {
114
+ await fs.rm(tmpRoot, { recursive: true, force: true })
115
+ }
116
+ })
117
+ })
@@ -0,0 +1,378 @@
1
+ /**
2
+ * File operations helper
3
+ *
4
+ * Single place that understands disk + conflicts. Provides:
5
+ * - listFiles: returns current filesystem state
6
+ * - detectConflicts: compares remote vs local and returns conflicts + safe writes
7
+ * - writeRemoteFiles: applies writes/deletes from remote
8
+ * - deleteLocalFile: removes a file from disk
9
+ *
10
+ * Controller decides WHEN to call these, but never computes conflicts itself.
11
+ */
12
+
13
+ import fs from "fs/promises"
14
+ import path from "path"
15
+ import type {
16
+ FileInfo,
17
+ ConflictResolution,
18
+ Conflict,
19
+ ConflictVersionData,
20
+ } from "../types.js"
21
+ import type { HashTracker } from "../utils/hashing.js"
22
+ import { normalizePath, sanitizeFilePath } from "@code-link/shared"
23
+ import { info, warn, debug } from "../utils/logging.js"
24
+ import {
25
+ hashFileContent,
26
+ type PersistedFileState,
27
+ } from "../utils/state-persistence.js"
28
+
29
+ const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
30
+ const DEFAULT_EXTENSION = ".tsx"
31
+ const DEFAULT_REMOTE_DRIFT_MS = 2000
32
+
33
+ /**
34
+ * Lists all supported files in the files directory
35
+ */
36
+ export async function listFiles(filesDir: string): Promise<FileInfo[]> {
37
+ const files: FileInfo[] = []
38
+
39
+ async function walk(currentDir: string): Promise<void> {
40
+ const entries = await fs.readdir(currentDir, { withFileTypes: true })
41
+
42
+ for (const entry of entries) {
43
+ const entryPath = path.join(currentDir, entry.name)
44
+
45
+ if (entry.isDirectory()) {
46
+ await walk(entryPath)
47
+ continue
48
+ }
49
+
50
+ if (!isSupportedExtension(entry.name)) continue
51
+
52
+ const relativePath = path.relative(filesDir, entryPath)
53
+ const normalizedPath = normalizePath(relativePath)
54
+ // Don't capitalize when listing existing files - preserve their actual names
55
+ const sanitizedPath = sanitizeFilePath(normalizedPath, false).path
56
+
57
+ try {
58
+ const [content, stats] = await Promise.all([
59
+ fs.readFile(entryPath, "utf-8"),
60
+ fs.stat(entryPath),
61
+ ])
62
+
63
+ files.push({
64
+ name: sanitizedPath,
65
+ content,
66
+ modifiedAt: stats.mtimeMs,
67
+ })
68
+ } catch (err) {
69
+ warn(`Failed to read ${entryPath}:`, err)
70
+ }
71
+ }
72
+ }
73
+
74
+ try {
75
+ await walk(filesDir)
76
+ } catch (err) {
77
+ warn("Failed to list files:", err)
78
+ }
79
+
80
+ return files
81
+ }
82
+
83
+ /**
84
+ * Detects conflicts between remote files and local filesystem
85
+ * Returns conflicts that need user resolution and safe writes that can be applied
86
+ */
87
+ export interface ConflictDetectionOptions {
88
+ preferRemote?: boolean
89
+ detectConflicts?: boolean
90
+ persistedState?: Map<string, PersistedFileState>
91
+ }
92
+
93
+ export async function detectConflicts(
94
+ remoteFiles: FileInfo[],
95
+ filesDir: string,
96
+ options: ConflictDetectionOptions = {}
97
+ ): Promise<ConflictResolution> {
98
+ const conflicts: Conflict[] = []
99
+ const writes: FileInfo[] = []
100
+ const localOnly: FileInfo[] = []
101
+ const detect = options.detectConflicts ?? true
102
+ const preferRemote = options.preferRemote ?? false
103
+ const persistedState = options.persistedState
104
+
105
+ debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
106
+
107
+ // Build a snapshot of all local files
108
+ const localFiles = await listFiles(filesDir)
109
+ const localFileMap = new Map(localFiles.map((f) => [f.name, f]))
110
+
111
+ // Track which files we've processed
112
+ const processedFiles = new Set<string>()
113
+
114
+ // Process remote files (remote-only or both sides)
115
+ for (const remote of remoteFiles) {
116
+ const normalized = resolveRemoteReference(filesDir, remote.name)
117
+ const local = localFileMap.get(normalized.relativePath)
118
+ processedFiles.add(normalized.relativePath)
119
+
120
+ const persisted = persistedState?.get(normalized.relativePath)
121
+ const localHash = local ? hashFileContent(local.content) : null
122
+ const localMatchesPersisted =
123
+ !!persisted && !!local && localHash === persisted.contentHash
124
+
125
+ if (!local) {
126
+ // Remote-only: download
127
+ writes.push({
128
+ name: normalized.relativePath,
129
+ content: remote.content,
130
+ modifiedAt: remote.modifiedAt,
131
+ })
132
+ continue
133
+ }
134
+
135
+ if (local.content === remote.content) {
136
+ // No change needed
137
+ continue
138
+ }
139
+
140
+ if (!detect || preferRemote) {
141
+ writes.push({
142
+ name: normalized.relativePath,
143
+ content: remote.content,
144
+ modifiedAt: remote.modifiedAt,
145
+ })
146
+ continue
147
+ }
148
+
149
+ // Check if local file is "clean" (matches last persisted state)
150
+ // If so, we can safely overwrite it with remote changes
151
+ // Both sides have the file with different content -> conflict
152
+ const localClean = persisted ? localMatchesPersisted : undefined
153
+ conflicts.push({
154
+ fileName: normalized.relativePath,
155
+ localContent: local.content,
156
+ remoteContent: remote.content,
157
+ localModifiedAt: local.modifiedAt,
158
+ remoteModifiedAt: remote.modifiedAt,
159
+ lastSyncedAt: persisted?.timestamp,
160
+ localClean,
161
+ })
162
+ }
163
+
164
+ // Process local-only files (not present in remote)
165
+ for (const local of localFiles) {
166
+ if (!processedFiles.has(local.name)) {
167
+ // Local-only: upload later
168
+ localOnly.push({
169
+ name: local.name,
170
+ content: local.content,
171
+ modifiedAt: local.modifiedAt,
172
+ })
173
+ }
174
+ }
175
+
176
+ return { conflicts, writes, localOnly }
177
+ }
178
+
179
+ export interface AutoResolveResult {
180
+ autoResolvedLocal: Conflict[]
181
+ autoResolvedRemote: Conflict[]
182
+ remainingConflicts: Conflict[]
183
+ }
184
+
185
+ export function autoResolveConflicts(
186
+ conflicts: Conflict[],
187
+ versions: ConflictVersionData[],
188
+ options: { remoteDriftMs?: number } = {}
189
+ ): AutoResolveResult {
190
+ const versionMap = new Map(
191
+ versions.map((version) => [version.fileName, version.latestRemoteVersionMs])
192
+ )
193
+ const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS
194
+
195
+ const autoResolvedLocal: Conflict[] = []
196
+ const autoResolvedRemote: Conflict[] = []
197
+ const remainingConflicts: Conflict[] = []
198
+
199
+ for (const conflict of conflicts) {
200
+ const latestRemoteVersionMs = versionMap.get(conflict.fileName)
201
+ const lastSyncedAt = conflict.lastSyncedAt
202
+
203
+ info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`)
204
+
205
+ if (!latestRemoteVersionMs) {
206
+ info(`-> No remote version data, keeping conflict`)
207
+ remainingConflicts.push(conflict)
208
+ continue
209
+ }
210
+
211
+ if (!lastSyncedAt) {
212
+ info(`-> No last sync timestamp, keeping conflict`)
213
+ remainingConflicts.push(conflict)
214
+ continue
215
+ }
216
+
217
+ info(
218
+ `-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`
219
+ )
220
+ info(
221
+ `-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`
222
+ )
223
+
224
+ const remoteUnchanged =
225
+ latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
226
+ const localClean = conflict.localClean === true
227
+
228
+ if (remoteUnchanged && !localClean) {
229
+ info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`)
230
+ autoResolvedLocal.push(conflict)
231
+ } else if (localClean && !remoteUnchanged) {
232
+ info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`)
233
+ autoResolvedRemote.push(conflict)
234
+ } else if (remoteUnchanged && localClean) {
235
+ info(` -> Both unchanged. Skipping (no conflict).`)
236
+ } else {
237
+ info(` -> Both sides changed. Real conflict.`)
238
+ remainingConflicts.push(conflict)
239
+ }
240
+ }
241
+
242
+ return {
243
+ autoResolvedLocal,
244
+ autoResolvedRemote,
245
+ remainingConflicts,
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Writes remote files to disk and updates hash tracker to prevent echoes
251
+ * CRITICAL: Update hashTracker BEFORE writing to disk
252
+ */
253
+ export async function writeRemoteFiles(
254
+ files: FileInfo[],
255
+ filesDir: string,
256
+ hashTracker: HashTracker,
257
+ installer?: { process: (fileName: string, content: string) => void }
258
+ ): Promise<void> {
259
+ info(`Writing ${files.length} remote files`)
260
+
261
+ for (const file of files) {
262
+ try {
263
+ const normalized = resolveRemoteReference(filesDir, file.name)
264
+ const fullPath = normalized.absolutePath
265
+
266
+ // Ensure directory exists
267
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
268
+
269
+ // CRITICAL ORDER: Update hash tracker FIRST (in memory)
270
+ hashTracker.remember(normalized.relativePath, file.content)
271
+
272
+ // THEN write to disk
273
+ await fs.writeFile(fullPath, file.content, "utf-8")
274
+
275
+ debug(`Wrote file: ${normalized.relativePath}`)
276
+
277
+ // Trigger type installer if available
278
+ installer?.process(normalized.relativePath, file.content)
279
+ } catch (err) {
280
+ warn(`Failed to write file ${file.name}:`, err)
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Deletes a local file from disk
287
+ */
288
+ export async function deleteLocalFile(
289
+ fileName: string,
290
+ filesDir: string,
291
+ hashTracker: HashTracker
292
+ ): Promise<void> {
293
+ const normalized = resolveRemoteReference(filesDir, fileName)
294
+
295
+ try {
296
+ // CRITICAL ORDER: Mark delete FIRST (in memory) to prevent echo
297
+ hashTracker.markDelete(normalized.relativePath)
298
+
299
+ // THEN delete from disk
300
+ await fs.unlink(normalized.absolutePath)
301
+
302
+ // Clear the hash immediately
303
+ hashTracker.forget(normalized.relativePath)
304
+
305
+ info(`Deleted file: ${normalized.relativePath}`)
306
+ } catch (err) {
307
+ const nodeError = err as NodeJS.ErrnoException
308
+
309
+ if (nodeError?.code === "ENOENT") {
310
+ // Treat missing files as already deleted to keep hash tracker in sync
311
+ hashTracker.forget(normalized.relativePath)
312
+ info(`File already deleted: ${normalized.relativePath}`)
313
+ return
314
+ }
315
+
316
+ // Clear pending delete marker immediately on failure
317
+ hashTracker.clearDelete(normalized.relativePath)
318
+ warn(`Failed to delete file ${fileName}:`, err)
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Reads a single file from disk (safe, returns null on error)
324
+ */
325
+ export async function readFileSafe(
326
+ fileName: string,
327
+ filesDir: string
328
+ ): Promise<string | null> {
329
+ const normalized = resolveRemoteReference(filesDir, fileName)
330
+
331
+ try {
332
+ return await fs.readFile(normalized.absolutePath, "utf-8")
333
+ } catch {
334
+ return null
335
+ }
336
+ }
337
+
338
+ async function readLocalSnapshot(absolutePath: string) {
339
+ try {
340
+ const [content, stats] = await Promise.all([
341
+ fs.readFile(absolutePath, "utf-8"),
342
+ fs.stat(absolutePath),
343
+ ])
344
+ return { content, modifiedAt: stats.mtimeMs }
345
+ } catch {
346
+ return null
347
+ }
348
+ }
349
+
350
+ function resolveRemoteReference(filesDir: string, rawName: string) {
351
+ const normalized = sanitizeRelativePath(rawName)
352
+ const absolutePath = path.join(filesDir, normalized.relativePath)
353
+ return { ...normalized, absolutePath }
354
+ }
355
+
356
+ function sanitizeRelativePath(relativePath: string) {
357
+ const trimmed = normalizePath(relativePath.trim())
358
+ const hasExtension = SUPPORTED_EXTENSIONS.some((ext) =>
359
+ trimmed.toLowerCase().endsWith(ext)
360
+ )
361
+ const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`
362
+ // Don't capitalize when processing remote files - preserve exact casing from Framer
363
+ const sanitized = sanitizeFilePath(candidate, false)
364
+ const normalized = normalizePath(sanitized.path)
365
+
366
+ return {
367
+ relativePath: normalized,
368
+ extension:
369
+ sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION,
370
+ }
371
+ }
372
+
373
+ function isSupportedExtension(fileName: string) {
374
+ const lower = fileName.toLowerCase()
375
+ return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext))
376
+ }
377
+
378
+ // Removed ensureSanitizedFileOnDisk as we want to trust user file names