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