framer-code-link 0.1.3 → 0.1.4

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.
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { WebSocketServer, WebSocket } from "ws"
9
9
  import type { IncomingMessage, OutgoingMessage } from "../types.js"
10
- import { info, error } from "../utils/logging.js"
10
+ import { debug, error } from "../utils/logging.js"
11
11
 
12
12
  export interface ConnectionCallbacks {
13
13
  onHandshake: (
@@ -68,11 +68,11 @@ export function initConnection(port: number): Promise<Connection> {
68
68
  // Server is ready when it starts listening
69
69
  wss.on("listening", () => {
70
70
  isReady = true
71
- info(`WebSocket server listening on port ${port}`)
71
+ debug(`WebSocket server listening on port ${port}`)
72
72
 
73
73
  wss.on("connection", (ws: WebSocket) => {
74
74
  const connId = ++connectionId
75
- info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`)
75
+ debug(`Client connected (conn ${connId})`)
76
76
 
77
77
  ws.on("message", (data: Buffer) => {
78
78
  try {
@@ -80,25 +80,25 @@ export function initConnection(port: number): Promise<Connection> {
80
80
 
81
81
  // Special handling for handshake
82
82
  if (message.type === "handshake") {
83
- info(`[CONN ${connId}] Received handshake`)
83
+ debug(`Received handshake (conn ${connId})`)
84
84
  handlers.onHandshake?.(ws, message)
85
85
  } else {
86
86
  handlers.onMessage?.(message)
87
87
  }
88
88
  } catch (err) {
89
- error(`[CONN ${connId}] Failed to parse message:`, err)
89
+ error(`Failed to parse message:`, err)
90
90
  }
91
91
  })
92
92
 
93
93
  ws.on("close", (code, reason) => {
94
- info(
95
- `[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
94
+ debug(
95
+ `Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
96
96
  )
97
97
  handlers.onDisconnect?.()
98
98
  })
99
99
 
100
100
  ws.on("error", (err) => {
101
- error(`[CONN ${connId}] WebSocket error:`, err)
101
+ error(`WebSocket error:`, err)
102
102
  })
103
103
  })
104
104
 
@@ -163,14 +163,14 @@ export function sendMessage(
163
163
  // Check socket state before attempting to send
164
164
  if (socket.readyState !== READY_STATE.OPEN) {
165
165
  const stateStr = readyStateToString(socket.readyState)
166
- info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`)
166
+ debug(`Cannot send ${message.type}: socket is ${stateStr}`)
167
167
  resolve(false)
168
168
  return
169
169
  }
170
170
 
171
171
  socket.send(JSON.stringify(message), (err) => {
172
172
  if (err) {
173
- error(`[WS] Send error for ${message.type}:`, err.message)
173
+ debug(`Send error for ${message.type}: ${err.message}`)
174
174
  resolve(false)
175
175
  } else {
176
176
  resolve(true)
@@ -18,9 +18,9 @@ import type {
18
18
  Conflict,
19
19
  ConflictVersionData,
20
20
  } from "../types.js"
21
- import type { HashTracker } from "../utils/hashing.js"
21
+ import type { HashTracker } from "../utils/hash-tracker.js"
22
22
  import { normalizePath, sanitizeFilePath } from "@code-link/shared"
23
- import { info, warn, debug } from "../utils/logging.js"
23
+ import { warn, debug } from "../utils/logging.js"
24
24
  import {
25
25
  hashFileContent,
26
26
  type PersistedFileState,
@@ -30,6 +30,11 @@ const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
30
30
  const DEFAULT_EXTENSION = ".tsx"
31
31
  const DEFAULT_REMOTE_DRIFT_MS = 2000
32
32
 
33
+ /** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
34
+ function normalizeForComparison(fileName: string): string {
35
+ return fileName.toLowerCase()
36
+ }
37
+
33
38
  /**
34
39
  * Lists all supported files in the files directory
35
40
  */
@@ -102,33 +107,64 @@ export async function detectConflicts(
102
107
  const preferRemote = options.preferRemote ?? false
103
108
  const persistedState = options.persistedState
104
109
 
110
+ const getPersistedState = (fileName: string) =>
111
+ persistedState?.get(normalizeForComparison(fileName)) ??
112
+ persistedState?.get(fileName)
113
+
105
114
  debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
106
115
 
107
- // Build a snapshot of all local files
116
+ // Build a snapshot of all local files (keyed by lowercase for case-insensitive matching)
108
117
  const localFiles = await listFiles(filesDir)
109
- const localFileMap = new Map(localFiles.map((f) => [f.name, f]))
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
+ )
110
129
 
111
- // Track which files we've processed
130
+ // Track which files we've processed (lowercase for case-insensitive matching)
112
131
  const processedFiles = new Set<string>()
113
132
 
114
133
  // Process remote files (remote-only or both sides)
115
134
  for (const remote of remoteFiles) {
116
135
  const normalized = resolveRemoteReference(filesDir, remote.name)
117
- const local = localFileMap.get(normalized.relativePath)
118
- processedFiles.add(normalized.relativePath)
136
+ const normalizedKey = normalizeForComparison(normalized.relativePath)
137
+ const local = localFileMap.get(normalizedKey)
138
+ processedFiles.add(normalizedKey)
119
139
 
120
- const persisted = persistedState?.get(normalized.relativePath)
140
+ const persisted = getPersistedState(normalized.relativePath)
121
141
  const localHash = local ? hashFileContent(local.content) : null
122
142
  const localMatchesPersisted =
123
143
  !!persisted && !!local && localHash === persisted.contentHash
124
144
 
125
145
  if (!local) {
126
- // Remote-only: download
127
- writes.push({
128
- name: normalized.relativePath,
129
- content: remote.content,
130
- modifiedAt: remote.modifiedAt,
131
- })
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
+ }
132
168
  continue
133
169
  }
134
170
 
@@ -163,13 +199,43 @@ export async function detectConflicts(
163
199
 
164
200
  // Process local-only files (not present in remote)
165
201
  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
- })
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
+ }
173
239
  }
174
240
  }
175
241
 
@@ -200,41 +266,37 @@ export function autoResolveConflicts(
200
266
  const latestRemoteVersionMs = versionMap.get(conflict.fileName)
201
267
  const lastSyncedAt = conflict.lastSyncedAt
202
268
 
203
- info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`)
269
+ debug(`Auto-resolve checking ${conflict.fileName}`)
204
270
 
205
271
  if (!latestRemoteVersionMs) {
206
- info(`-> No remote version data, keeping conflict`)
272
+ debug(` No remote version data, keeping conflict`)
207
273
  remainingConflicts.push(conflict)
208
274
  continue
209
275
  }
210
276
 
211
277
  if (!lastSyncedAt) {
212
- info(`-> No last sync timestamp, keeping conflict`)
278
+ debug(` No last sync timestamp, keeping conflict`)
213
279
  remainingConflicts.push(conflict)
214
280
  continue
215
281
  }
216
282
 
217
- info(
218
- `-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`
219
- )
220
- info(
221
- `-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`
222
- )
283
+ debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`)
284
+ debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`)
223
285
 
224
286
  const remoteUnchanged =
225
287
  latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
226
288
  const localClean = conflict.localClean === true
227
289
 
228
290
  if (remoteUnchanged && !localClean) {
229
- info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`)
291
+ debug(` Remote unchanged, local changed -> LOCAL`)
230
292
  autoResolvedLocal.push(conflict)
231
293
  } else if (localClean && !remoteUnchanged) {
232
- info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`)
294
+ debug(` Local unchanged, remote changed -> REMOTE`)
233
295
  autoResolvedRemote.push(conflict)
234
296
  } else if (remoteUnchanged && localClean) {
235
- info(` -> Both unchanged. Skipping (no conflict).`)
297
+ debug(` Both unchanged, skipping`)
236
298
  } else {
237
- info(` -> Both sides changed. Real conflict.`)
299
+ debug(` Both changed, real conflict`)
238
300
  remainingConflicts.push(conflict)
239
301
  }
240
302
  }
@@ -256,7 +318,7 @@ export async function writeRemoteFiles(
256
318
  hashTracker: HashTracker,
257
319
  installer?: { process: (fileName: string, content: string) => void }
258
320
  ): Promise<void> {
259
- info(`Writing ${files.length} remote files`)
321
+ debug(`Writing ${files.length} remote files`)
260
322
 
261
323
  for (const file of files) {
262
324
  try {
@@ -302,14 +364,14 @@ export async function deleteLocalFile(
302
364
  // Clear the hash immediately
303
365
  hashTracker.forget(normalized.relativePath)
304
366
 
305
- info(`Deleted file: ${normalized.relativePath}`)
367
+ debug(`Deleted file: ${normalized.relativePath}`)
306
368
  } catch (err) {
307
369
  const nodeError = err as NodeJS.ErrnoException
308
370
 
309
371
  if (nodeError?.code === "ENOENT") {
310
372
  // Treat missing files as already deleted to keep hash tracker in sync
311
373
  hashTracker.forget(normalized.relativePath)
312
- info(`File already deleted: ${normalized.relativePath}`)
374
+ debug(`File already deleted: ${normalized.relativePath}`)
313
375
  return
314
376
  }
315
377
 
@@ -11,7 +11,7 @@ import ts from "typescript"
11
11
  import path from "path"
12
12
  import fs from "fs/promises"
13
13
  import { extractImports } from "../utils/imports.js"
14
- import { info, debug, warn } from "../utils/logging.js"
14
+ import { debug, warn } from "../utils/logging.js"
15
15
 
16
16
  export interface InstallerConfig {
17
17
  projectDir: string
@@ -85,7 +85,7 @@ export class Installer {
85
85
 
86
86
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
87
87
  seenPackages.add(pkgMatch[1])
88
- info(`📦 Types: ${pkgMatch[1]}`)
88
+ debug(`📦 Types: ${pkgMatch[1]}`)
89
89
  }
90
90
 
91
91
  await this.writeTypeFile(receivedPath, code)
@@ -93,7 +93,7 @@ export class Installer {
93
93
  },
94
94
  })
95
95
 
96
- info("Type installer initialized")
96
+ debug("Type installer initialized")
97
97
  }
98
98
 
99
99
  /**
@@ -181,7 +181,7 @@ export class Installer {
181
181
  }
182
182
 
183
183
  this.processedImports.add(hash)
184
- info(`Processing imports for ${fileName} (${imports.length} packages)`)
184
+ debug(`Processing imports for ${fileName} (${imports.length} packages)`)
185
185
 
186
186
  try {
187
187
  await this.ata(content)
@@ -318,7 +318,7 @@ export class Installer {
318
318
  include: ["files/**/*", "framer-modules.d.ts"],
319
319
  }
320
320
  await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2))
321
- info("Created tsconfig.json")
321
+ debug("Created tsconfig.json")
322
322
  }
323
323
  }
324
324
 
@@ -334,7 +334,7 @@ export class Installer {
334
334
  trailingComma: "es5",
335
335
  }
336
336
  await fs.writeFile(prettierPath, JSON.stringify(config, null, 2))
337
- info("Created .prettierrc")
337
+ debug("Created .prettierrc")
338
338
  }
339
339
  }
340
340
 
@@ -352,7 +352,7 @@ declare module "https://framerusercontent.com/*"
352
352
  declare module "*.json"
353
353
  `
354
354
  await fs.writeFile(declarationsPath, declarations)
355
- info("Created framer-modules.d.ts")
355
+ debug("Created framer-modules.d.ts")
356
356
  }
357
357
  }
358
358
 
@@ -369,7 +369,7 @@ declare module "*.json"
369
369
  description: "Framer files synced with framer-code-link",
370
370
  }
371
371
  await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2))
372
- info("Created package.json")
372
+ debug("Created package.json")
373
373
  }
374
374
  }
375
375
 
@@ -390,9 +390,9 @@ declare module "*.json"
390
390
  if (
391
391
  await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)
392
392
  ) {
393
- info("📦 React types (from cache)")
393
+ debug("📦 React types (from cache)")
394
394
  } else {
395
- info("Downloading React 18 types for Framer compatibility...")
395
+ debug("Downloading React 18 types...")
396
396
  await this.downloadTypePackage(
397
397
  "@types/react",
398
398
  REACT_TYPES_VERSION,
@@ -415,7 +415,7 @@ declare module "*.json"
415
415
  reactDomFiles
416
416
  )
417
417
  ) {
418
- info("📦 React DOM types (from cache)")
418
+ debug("📦 React DOM types (from cache)")
419
419
  } else {
420
420
  await this.downloadTypePackage(
421
421
  "@types/react-dom",
@@ -8,7 +8,7 @@
8
8
  import type { WebSocket } from "ws"
9
9
  import type { Conflict } from "../types.js"
10
10
  import { sendMessage } from "./connection.js"
11
- import { info, warn } from "../utils/logging.js"
11
+ import { debug, warn } from "../utils/logging.js"
12
12
 
13
13
  class PluginDisconnectedError extends Error {
14
14
  constructor() {
@@ -55,9 +55,7 @@ export class UserActionCoordinator {
55
55
  return await confirmationPromise
56
56
  } catch (err) {
57
57
  if (err instanceof PluginDisconnectedError) {
58
- info(
59
- `[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`
60
- )
58
+ debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`)
61
59
  return false
62
60
  }
63
61
  throw err
@@ -111,9 +109,7 @@ export class UserActionCoordinator {
111
109
  return new Map(results)
112
110
  } catch (err) {
113
111
  if (err instanceof PluginDisconnectedError) {
114
- info(
115
- "[USER-ACTION] Plugin disconnected while awaiting conflict decisions"
116
- )
112
+ debug("Plugin disconnected while awaiting conflict decisions")
117
113
  return new Map()
118
114
  }
119
115
  throw err
@@ -129,7 +125,7 @@ export class UserActionCoordinator {
129
125
  ): Promise<T> {
130
126
  return new Promise<T>((resolve, reject) => {
131
127
  this.pendingActions.set(actionId, { resolve, reject })
132
- info(`[USER-ACTION] Awaiting ${description}: ${actionId}`)
128
+ debug(`Awaiting ${description}: ${actionId}`)
133
129
  })
134
130
  }
135
131
 
@@ -139,13 +135,13 @@ export class UserActionCoordinator {
139
135
  handleConfirmation<T>(actionId: string, value: T): boolean {
140
136
  const pending = this.pendingActions.get(actionId)
141
137
  if (!pending) {
142
- warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`)
138
+ debug(`Unexpected confirmation for ${actionId}`)
143
139
  return false
144
140
  }
145
141
 
146
142
  this.pendingActions.delete(actionId)
147
143
  pending.resolve(value)
148
- info(`[USER-ACTION] Confirmed: ${actionId}`)
144
+ debug(`Confirmed: ${actionId}`)
149
145
  return true
150
146
  }
151
147
 
@@ -155,7 +151,7 @@ export class UserActionCoordinator {
155
151
  cleanup(): void {
156
152
  for (const [actionId, pending] of this.pendingActions.entries()) {
157
153
  pending.reject(new PluginDisconnectedError())
158
- warn(`[USER-ACTION] Cancelled pending action: ${actionId}`)
154
+ debug(`Cancelled pending action: ${actionId}`)
159
155
  }
160
156
  this.pendingActions.clear()
161
157
  }
@@ -16,7 +16,7 @@ import {
16
16
  sanitizeFilePath,
17
17
  } from "@code-link/shared"
18
18
  import { getRelativePath } from "../utils/paths.js"
19
- import { info, debug, warn } from "../utils/logging.js"
19
+ import { debug, warn } from "../utils/logging.js"
20
20
 
21
21
  export interface Watcher {
22
22
  on(event: "change", handler: (event: WatcherEvent) => void): void
@@ -35,7 +35,7 @@ export function initWatcher(filesDir: string): Watcher {
35
35
  ignoreInitial: false, // Emit add events for existing files so we can sanitize them
36
36
  })
37
37
 
38
- info(`Watching directory: ${filesDir}`)
38
+ debug(`Watching directory: ${filesDir}`)
39
39
 
40
40
  // Helper to emit normalized events
41
41
  const emitEvent = async (
@@ -62,15 +62,10 @@ export function initWatcher(filesDir: string): Watcher {
62
62
  try {
63
63
  await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
64
64
  await fs.rename(absolutePath, newAbsolutePath)
65
- info(
66
- `Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`
67
- )
65
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`)
68
66
  effectiveAbsolutePath = newAbsolutePath
69
67
  } catch (err) {
70
- warn(
71
- `Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`,
72
- err
73
- )
68
+ warn(`Failed to rename ${rawRelativePath}`, err)
74
69
  }
75
70
  }
76
71
 
package/src/index.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  import { Command } from "commander"
11
11
  import { start } from "./controller.js"
12
12
  import type { Config } from "./types.js"
13
- import { setLogLevel, LogLevel, info } from "./utils/logging.js"
14
- import { getPortFromHash } from "./utils/hashing.js"
13
+ import { setLogLevel, LogLevel, banner, warn } from "./utils/logging.js"
14
+ import { getPortFromHash } from "@code-link/shared"
15
15
  import { getProjectHashFromCwd } from "./utils/project.js"
16
16
 
17
17
  const program = new Command()
@@ -77,6 +77,9 @@ program
77
77
 
78
78
  const port = getPortFromHash(projectHash)
79
79
 
80
+ // Show startup banner
81
+ banner("0.1.3", port)
82
+
80
83
  const config: Config = {
81
84
  port,
82
85
  projectHash,
@@ -88,8 +91,8 @@ program
88
91
  }
89
92
 
90
93
  if (config.dangerouslyAutoDelete) {
91
- info(
92
- "⚠️ Auto-delete mode enabled - files will be deleted without confirmation"
94
+ warn(
95
+ "Auto-delete mode enabled - files will be deleted without confirmation"
93
96
  )
94
97
  }
95
98
 
package/src/types.ts CHANGED
@@ -32,8 +32,10 @@ export interface LocalFile {
32
32
  // Conflict detection
33
33
  export interface Conflict {
34
34
  fileName: string
35
- localContent: string
36
- remoteContent: string
35
+ /** null means the file was deleted locally */
36
+ localContent: string | null
37
+ /** null means the file was deleted in Framer */
38
+ remoteContent: string | null
37
39
  localModifiedAt?: number
38
40
  remoteModifiedAt?: number
39
41
  lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
@@ -72,24 +72,8 @@ export function createHashTracker(): HashTracker {
72
72
  }
73
73
 
74
74
  /**
75
- * Computes a hash of file content for comparison
75
+ * Computes a SHA256 hash of file content for comparison
76
76
  */
77
77
  function hashContent(content: string): string {
78
78
  return createHash("sha256").update(content).digest("hex")
79
79
  }
80
-
81
- /**
82
- * Generate a deterministic port number from a project hash
83
- * Port range: 3847-4096 (250 possible ports)
84
- * Uses simple hash to match client-side implementation
85
- */
86
- export function getPortFromHash(projectHash: string): number {
87
- let hash = 0
88
- for (let i = 0; i < projectHash.length; i++) {
89
- const char = projectHash.charCodeAt(i)
90
- hash = (hash << 5) - hash + char
91
- hash = hash & hash // Convert to 32bit integer
92
- }
93
- const portOffset = Math.abs(hash) % 250
94
- return 3847 + portOffset
95
- }