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.
@@ -1,440 +0,0 @@
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/hash-tracker.js"
22
- import { normalizePath, sanitizeFilePath } from "@code-link/shared"
23
- import { 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
- /** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
34
- function normalizeForComparison(fileName: string): string {
35
- return fileName.toLowerCase()
36
- }
37
-
38
- /**
39
- * Lists all supported files in the files directory
40
- */
41
- export async function listFiles(filesDir: string): Promise<FileInfo[]> {
42
- const files: FileInfo[] = []
43
-
44
- async function walk(currentDir: string): Promise<void> {
45
- const entries = await fs.readdir(currentDir, { withFileTypes: true })
46
-
47
- for (const entry of entries) {
48
- const entryPath = path.join(currentDir, entry.name)
49
-
50
- if (entry.isDirectory()) {
51
- await walk(entryPath)
52
- continue
53
- }
54
-
55
- if (!isSupportedExtension(entry.name)) continue
56
-
57
- const relativePath = path.relative(filesDir, entryPath)
58
- const normalizedPath = normalizePath(relativePath)
59
- // Don't capitalize when listing existing files - preserve their actual names
60
- const sanitizedPath = sanitizeFilePath(normalizedPath, false).path
61
-
62
- try {
63
- const [content, stats] = await Promise.all([
64
- fs.readFile(entryPath, "utf-8"),
65
- fs.stat(entryPath),
66
- ])
67
-
68
- files.push({
69
- name: sanitizedPath,
70
- content,
71
- modifiedAt: stats.mtimeMs,
72
- })
73
- } catch (err) {
74
- warn(`Failed to read ${entryPath}:`, err)
75
- }
76
- }
77
- }
78
-
79
- try {
80
- await walk(filesDir)
81
- } catch (err) {
82
- warn("Failed to list files:", err)
83
- }
84
-
85
- return files
86
- }
87
-
88
- /**
89
- * Detects conflicts between remote files and local filesystem
90
- * Returns conflicts that need user resolution and safe writes that can be applied
91
- */
92
- export interface ConflictDetectionOptions {
93
- preferRemote?: boolean
94
- detectConflicts?: boolean
95
- persistedState?: Map<string, PersistedFileState>
96
- }
97
-
98
- export async function detectConflicts(
99
- remoteFiles: FileInfo[],
100
- filesDir: string,
101
- options: ConflictDetectionOptions = {}
102
- ): Promise<ConflictResolution> {
103
- const conflicts: Conflict[] = []
104
- const writes: FileInfo[] = []
105
- const localOnly: FileInfo[] = []
106
- const detect = options.detectConflicts ?? true
107
- const preferRemote = options.preferRemote ?? false
108
- const persistedState = options.persistedState
109
-
110
- const getPersistedState = (fileName: string) =>
111
- persistedState?.get(normalizeForComparison(fileName)) ??
112
- persistedState?.get(fileName)
113
-
114
- debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
115
-
116
- // Build a snapshot of all local files (keyed by lowercase for case-insensitive matching)
117
- const localFiles = await listFiles(filesDir)
118
- const localFileMap = new Map(
119
- localFiles.map((f) => [normalizeForComparison(f.name), f])
120
- )
121
-
122
- // Build a set of remote file names for quick lookup (lowercase keys)
123
- const remoteFileMap = new Map(
124
- remoteFiles.map((f) => {
125
- const normalized = resolveRemoteReference(filesDir, f.name)
126
- return [normalizeForComparison(normalized.relativePath), f]
127
- })
128
- )
129
-
130
- // Track which files we've processed (lowercase for case-insensitive matching)
131
- const processedFiles = new Set<string>()
132
-
133
- // Process remote files (remote-only or both sides)
134
- for (const remote of remoteFiles) {
135
- const normalized = resolveRemoteReference(filesDir, remote.name)
136
- const normalizedKey = normalizeForComparison(normalized.relativePath)
137
- const local = localFileMap.get(normalizedKey)
138
- processedFiles.add(normalizedKey)
139
-
140
- const persisted = getPersistedState(normalized.relativePath)
141
- const localHash = local ? hashFileContent(local.content) : null
142
- const localMatchesPersisted =
143
- !!persisted && !!local && localHash === persisted.contentHash
144
-
145
- if (!local) {
146
- // File exists in remote but not locally
147
- if (persisted) {
148
- // File was previously synced but now missing locally → deleted locally while offline
149
- // This is a conflict: local=null (deleted), remote=content
150
- debug(
151
- `Conflict: ${normalized.relativePath} deleted locally while offline`
152
- )
153
- conflicts.push({
154
- fileName: normalized.relativePath,
155
- localContent: null,
156
- remoteContent: remote.content,
157
- remoteModifiedAt: remote.modifiedAt,
158
- lastSyncedAt: persisted?.timestamp,
159
- })
160
- } else {
161
- // New file from remote (never synced before): download
162
- writes.push({
163
- name: normalized.relativePath,
164
- content: remote.content,
165
- modifiedAt: remote.modifiedAt,
166
- })
167
- }
168
- continue
169
- }
170
-
171
- if (local.content === remote.content) {
172
- // No change needed
173
- continue
174
- }
175
-
176
- if (!detect || preferRemote) {
177
- writes.push({
178
- name: normalized.relativePath,
179
- content: remote.content,
180
- modifiedAt: remote.modifiedAt,
181
- })
182
- continue
183
- }
184
-
185
- // Check if local file is "clean" (matches last persisted state)
186
- // If so, we can safely overwrite it with remote changes
187
- // Both sides have the file with different content -> conflict
188
- const localClean = persisted ? localMatchesPersisted : undefined
189
- conflicts.push({
190
- fileName: normalized.relativePath,
191
- localContent: local.content,
192
- remoteContent: remote.content,
193
- localModifiedAt: local.modifiedAt,
194
- remoteModifiedAt: remote.modifiedAt,
195
- lastSyncedAt: persisted?.timestamp,
196
- localClean,
197
- })
198
- }
199
-
200
- // Process local-only files (not present in remote)
201
- for (const local of localFiles) {
202
- const localKey = normalizeForComparison(local.name)
203
- if (!processedFiles.has(localKey)) {
204
- const persisted = getPersistedState(local.name)
205
- if (persisted) {
206
- // File was previously synced but now missing from remote → deleted in Framer while offline
207
- // This is a conflict: local=content, remote=null (deleted)
208
- debug(`Conflict: ${local.name} deleted in Framer while offline`)
209
- conflicts.push({
210
- fileName: local.name,
211
- localContent: local.content,
212
- remoteContent: null,
213
- localModifiedAt: local.modifiedAt,
214
- lastSyncedAt: persisted?.timestamp,
215
- })
216
- } else {
217
- // New local file (never synced before): upload later
218
- localOnly.push({
219
- name: local.name,
220
- content: local.content,
221
- modifiedAt: local.modifiedAt,
222
- })
223
- }
224
- }
225
- }
226
-
227
- // Check for files in persisted state that are missing from BOTH sides
228
- // These were deleted on both sides while offline - auto-clean them (no conflict)
229
- if (persistedState) {
230
- for (const [fileName] of persistedState) {
231
- const normalizedKey = normalizeForComparison(fileName)
232
- const inLocal = localFileMap.has(normalizedKey)
233
- const inRemote = remoteFileMap.has(normalizedKey)
234
- if (!inLocal && !inRemote) {
235
- debug(`[AUTO-RESOLVE] ${fileName}: deleted on both sides, no conflict`)
236
- // No action needed - the file is gone from both sides
237
- // The persisted state will be cleaned up when we persist
238
- }
239
- }
240
- }
241
-
242
- return { conflicts, writes, localOnly }
243
- }
244
-
245
- export interface AutoResolveResult {
246
- autoResolvedLocal: Conflict[]
247
- autoResolvedRemote: Conflict[]
248
- remainingConflicts: Conflict[]
249
- }
250
-
251
- export function autoResolveConflicts(
252
- conflicts: Conflict[],
253
- versions: ConflictVersionData[],
254
- options: { remoteDriftMs?: number } = {}
255
- ): AutoResolveResult {
256
- const versionMap = new Map(
257
- versions.map((version) => [version.fileName, version.latestRemoteVersionMs])
258
- )
259
- const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS
260
-
261
- const autoResolvedLocal: Conflict[] = []
262
- const autoResolvedRemote: Conflict[] = []
263
- const remainingConflicts: Conflict[] = []
264
-
265
- for (const conflict of conflicts) {
266
- const latestRemoteVersionMs = versionMap.get(conflict.fileName)
267
- const lastSyncedAt = conflict.lastSyncedAt
268
-
269
- debug(`Auto-resolve checking ${conflict.fileName}`)
270
-
271
- if (!latestRemoteVersionMs) {
272
- debug(` No remote version data, keeping conflict`)
273
- remainingConflicts.push(conflict)
274
- continue
275
- }
276
-
277
- if (!lastSyncedAt) {
278
- debug(` No last sync timestamp, keeping conflict`)
279
- remainingConflicts.push(conflict)
280
- continue
281
- }
282
-
283
- debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`)
284
- debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`)
285
-
286
- const remoteUnchanged =
287
- latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
288
- const localClean = conflict.localClean === true
289
-
290
- if (remoteUnchanged && !localClean) {
291
- debug(` Remote unchanged, local changed -> LOCAL`)
292
- autoResolvedLocal.push(conflict)
293
- } else if (localClean && !remoteUnchanged) {
294
- debug(` Local unchanged, remote changed -> REMOTE`)
295
- autoResolvedRemote.push(conflict)
296
- } else if (remoteUnchanged && localClean) {
297
- debug(` Both unchanged, skipping`)
298
- } else {
299
- debug(` Both changed, real conflict`)
300
- remainingConflicts.push(conflict)
301
- }
302
- }
303
-
304
- return {
305
- autoResolvedLocal,
306
- autoResolvedRemote,
307
- remainingConflicts,
308
- }
309
- }
310
-
311
- /**
312
- * Writes remote files to disk and updates hash tracker to prevent echoes
313
- * CRITICAL: Update hashTracker BEFORE writing to disk
314
- */
315
- export async function writeRemoteFiles(
316
- files: FileInfo[],
317
- filesDir: string,
318
- hashTracker: HashTracker,
319
- installer?: { process: (fileName: string, content: string) => void }
320
- ): Promise<void> {
321
- debug(`Writing ${files.length} remote files`)
322
-
323
- for (const file of files) {
324
- try {
325
- const normalized = resolveRemoteReference(filesDir, file.name)
326
- const fullPath = normalized.absolutePath
327
-
328
- // Ensure directory exists
329
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
330
-
331
- // CRITICAL ORDER: Update hash tracker FIRST (in memory)
332
- hashTracker.remember(normalized.relativePath, file.content)
333
-
334
- // THEN write to disk
335
- await fs.writeFile(fullPath, file.content, "utf-8")
336
-
337
- debug(`Wrote file: ${normalized.relativePath}`)
338
-
339
- // Trigger type installer if available
340
- installer?.process(normalized.relativePath, file.content)
341
- } catch (err) {
342
- warn(`Failed to write file ${file.name}:`, err)
343
- }
344
- }
345
- }
346
-
347
- /**
348
- * Deletes a local file from disk
349
- */
350
- export async function deleteLocalFile(
351
- fileName: string,
352
- filesDir: string,
353
- hashTracker: HashTracker
354
- ): Promise<void> {
355
- const normalized = resolveRemoteReference(filesDir, fileName)
356
-
357
- try {
358
- // CRITICAL ORDER: Mark delete FIRST (in memory) to prevent echo
359
- hashTracker.markDelete(normalized.relativePath)
360
-
361
- // THEN delete from disk
362
- await fs.unlink(normalized.absolutePath)
363
-
364
- // Clear the hash immediately
365
- hashTracker.forget(normalized.relativePath)
366
-
367
- debug(`Deleted file: ${normalized.relativePath}`)
368
- } catch (err) {
369
- const nodeError = err as NodeJS.ErrnoException
370
-
371
- if (nodeError?.code === "ENOENT") {
372
- // Treat missing files as already deleted to keep hash tracker in sync
373
- hashTracker.forget(normalized.relativePath)
374
- debug(`File already deleted: ${normalized.relativePath}`)
375
- return
376
- }
377
-
378
- // Clear pending delete marker immediately on failure
379
- hashTracker.clearDelete(normalized.relativePath)
380
- warn(`Failed to delete file ${fileName}:`, err)
381
- }
382
- }
383
-
384
- /**
385
- * Reads a single file from disk (safe, returns null on error)
386
- */
387
- export async function readFileSafe(
388
- fileName: string,
389
- filesDir: string
390
- ): Promise<string | null> {
391
- const normalized = resolveRemoteReference(filesDir, fileName)
392
-
393
- try {
394
- return await fs.readFile(normalized.absolutePath, "utf-8")
395
- } catch {
396
- return null
397
- }
398
- }
399
-
400
- async function readLocalSnapshot(absolutePath: string) {
401
- try {
402
- const [content, stats] = await Promise.all([
403
- fs.readFile(absolutePath, "utf-8"),
404
- fs.stat(absolutePath),
405
- ])
406
- return { content, modifiedAt: stats.mtimeMs }
407
- } catch {
408
- return null
409
- }
410
- }
411
-
412
- function resolveRemoteReference(filesDir: string, rawName: string) {
413
- const normalized = sanitizeRelativePath(rawName)
414
- const absolutePath = path.join(filesDir, normalized.relativePath)
415
- return { ...normalized, absolutePath }
416
- }
417
-
418
- function sanitizeRelativePath(relativePath: string) {
419
- const trimmed = normalizePath(relativePath.trim())
420
- const hasExtension = SUPPORTED_EXTENSIONS.some((ext) =>
421
- trimmed.toLowerCase().endsWith(ext)
422
- )
423
- const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`
424
- // Don't capitalize when processing remote files - preserve exact casing from Framer
425
- const sanitized = sanitizeFilePath(candidate, false)
426
- const normalized = normalizePath(sanitized.path)
427
-
428
- return {
429
- relativePath: normalized,
430
- extension:
431
- sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION,
432
- }
433
- }
434
-
435
- function isSupportedExtension(fileName: string) {
436
- const lower = fileName.toLowerCase()
437
- return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext))
438
- }
439
-
440
- // Removed ensureSanitizedFileOnDisk as we want to trust user file names