framer-code-link 0.1.4 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  "devDependencies": {
30
30
  "@types/node": "^22.19.2",
31
31
  "@types/ws": "^8.18.1",
32
- "tsdown": "^0.17.3",
32
+ "tsdown": "^0.17.4",
33
33
  "tsx": "^4.21.0",
34
- "vitest": "^2.1.9"
34
+ "vitest": "^4.0.15"
35
35
  }
36
36
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { transition } from "./controller.js"
2
+ import { filterEchoedFiles, transition } from "./controller.js"
3
+ import { createHashTracker } from "./utils/hash-tracker.js"
3
4
  import type { WebSocket } from "ws"
4
5
  describe("State Machine", () => {
5
6
  describe("HANDSHAKE transition", () => {
@@ -848,4 +849,43 @@ describe("State Machine", () => {
848
849
  }
849
850
  })
850
851
  })
852
+
853
+ describe("echo prevention filter", () => {
854
+ it("skips inbound file-change that matches last local send", () => {
855
+ const hashTracker = createHashTracker()
856
+ hashTracker.remember("Hey.tsx", "content")
857
+
858
+ const filtered = filterEchoedFiles(
859
+ [
860
+ {
861
+ name: "Hey.tsx",
862
+ content: "content",
863
+ modifiedAt: Date.now(),
864
+ },
865
+ ],
866
+ hashTracker
867
+ )
868
+
869
+ expect(filtered).toHaveLength(0)
870
+ })
871
+
872
+ it("keeps inbound change when content differs", () => {
873
+ const hashTracker = createHashTracker()
874
+ hashTracker.remember("Hey.tsx", "old content")
875
+
876
+ const filtered = filterEchoedFiles(
877
+ [
878
+ {
879
+ name: "Hey.tsx",
880
+ content: "new content",
881
+ modifiedAt: Date.now(),
882
+ },
883
+ ],
884
+ hashTracker
885
+ )
886
+
887
+ expect(filtered).toHaveLength(1)
888
+ expect(filtered[0]?.content).toBe("new content")
889
+ })
890
+ })
851
891
  })
package/src/controller.ts CHANGED
@@ -161,7 +161,12 @@ type Effect =
161
161
  | { type: "SEND_MESSAGE"; payload: OutgoingMessage }
162
162
  | { type: "LIST_LOCAL_FILES" }
163
163
  | { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
164
- | { type: "WRITE_FILES"; files: FileInfo[]; silent?: boolean }
164
+ | {
165
+ type: "WRITE_FILES"
166
+ files: FileInfo[]
167
+ silent?: boolean
168
+ skipEcho?: boolean // when true, skip writes that match hashTracker (inbound echo)
169
+ }
165
170
  | { type: "DELETE_LOCAL_FILES"; names: string[] }
166
171
  | { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
167
172
  | { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
@@ -199,6 +204,20 @@ function log(level: "info" | "debug" | "warn", message: string): Effect {
199
204
  return { type: "LOG", level, message }
200
205
  }
201
206
 
207
+ /**
208
+ * Filter out files whose content matches the last remembered hash.
209
+ * Used to skip inbound echoes of our own local sends.
210
+ */
211
+ export function filterEchoedFiles(
212
+ files: FileInfo[],
213
+ hashTracker: ReturnType<typeof createHashTracker>
214
+ ): FileInfo[] {
215
+ return files.filter((file) => {
216
+ if (file.content === undefined) return true
217
+ return !hashTracker.shouldSkip(file.name, file.content)
218
+ })
219
+ }
220
+
202
221
  /**
203
222
  * Pure state transition function
204
223
  * Takes current state + event, returns new state + effects to execute
@@ -336,6 +355,7 @@ function transition(
336
355
  // - safeWrites = files we can apply (remote-only or local unchanged)
337
356
  // - conflicts = files that need manual resolution (content or deletion conflicts)
338
357
  // - localOnly = files to upload
358
+ // (unchanged files have metadata recorded in DETECT_CONFLICTS executor)
339
359
 
340
360
  // Apply safe writes
341
361
  if (safeWrites.length > 0) {
@@ -383,7 +403,6 @@ function transition(
383
403
  }
384
404
 
385
405
  // No conflicts - transition to watching
386
- const totalSynced = safeWrites.length + localOnly.length
387
406
  const remoteTotal = state.queuedDiffs.length
388
407
  const totalCount = remoteTotal + localOnly.length
389
408
  const updatedCount = safeWrites.length + localOnly.length
@@ -447,6 +466,7 @@ function transition(
447
466
  effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
448
467
  type: "WRITE_FILES",
449
468
  files: [event.file],
469
+ skipEcho: true,
450
470
  })
451
471
 
452
472
  return { state, effects }
@@ -832,12 +852,22 @@ async function executeEffect(
832
852
  }
833
853
 
834
854
  // Use existing helper to detect conflicts
835
- const { conflicts, writes, localOnly } = await detectConflicts(
855
+ const { conflicts, writes, localOnly, unchanged } = await detectConflicts(
836
856
  effect.remoteFiles,
837
857
  config.filesDir,
838
858
  { persistedState: fileMetadataCache.getPersistedState() }
839
859
  )
840
860
 
861
+ // Record metadata for unchanged files so watcher add events get skipped
862
+ // (chokidar ignoreInitial=false fires late adds that would otherwise re-upload)
863
+ for (const file of unchanged) {
864
+ fileMetadataCache.recordRemoteWrite(
865
+ file.name,
866
+ file.content,
867
+ file.modifiedAt ?? Date.now()
868
+ )
869
+ }
870
+
841
871
  // Return CONFLICTS_DETECTED event to continue the flow
842
872
  return [
843
873
  {
@@ -863,13 +893,29 @@ async function executeEffect(
863
893
 
864
894
  case "WRITE_FILES": {
865
895
  if (config.filesDir) {
896
+ // skipEcho is opt-in: some callers still need side-effects (metadata/logs)
897
+ // even when content matches the last hash tracked in-memory.
898
+ const filesToWrite =
899
+ effect.skipEcho === true
900
+ ? filterEchoedFiles(effect.files, hashTracker)
901
+ : effect.files
902
+
903
+ if (effect.skipEcho && filesToWrite.length !== effect.files.length) {
904
+ const skipped = effect.files.length - filesToWrite.length
905
+ debug(`Skipped ${pluralize(skipped, "echoed change")}`)
906
+ }
907
+
908
+ if (filesToWrite.length === 0) {
909
+ return []
910
+ }
911
+
866
912
  await writeRemoteFiles(
867
- effect.files,
913
+ filesToWrite,
868
914
  config.filesDir,
869
915
  hashTracker,
870
916
  installer ?? undefined
871
917
  )
872
- for (const file of effect.files) {
918
+ for (const file of filesToWrite) {
873
919
  if (!effect.silent) {
874
920
  fileDown(file.name)
875
921
  }
@@ -1056,6 +1102,11 @@ async function executeEffect(
1056
1102
  case "SYNC_COMPLETE": {
1057
1103
  const wasDisconnected = wasRecentlyDisconnected()
1058
1104
 
1105
+ // Notify plugin that sync is complete
1106
+ if (syncState.socket) {
1107
+ await sendMessage(syncState.socket, { type: "sync-complete" })
1108
+ }
1109
+
1059
1110
  if (wasDisconnected) {
1060
1111
  // Only show reconnect message if we actually showed the disconnect notice
1061
1112
  if (didShowDisconnect()) {
@@ -72,6 +72,7 @@ export function initConnection(port: number): Promise<Connection> {
72
72
 
73
73
  wss.on("connection", (ws: WebSocket) => {
74
74
  const connId = ++connectionId
75
+ let handshakeReceived = false
75
76
  debug(`Client connected (conn ${connId})`)
76
77
 
77
78
  ws.on("message", (data: Buffer) => {
@@ -81,9 +82,15 @@ export function initConnection(port: number): Promise<Connection> {
81
82
  // Special handling for handshake
82
83
  if (message.type === "handshake") {
83
84
  debug(`Received handshake (conn ${connId})`)
85
+ handshakeReceived = true
84
86
  handlers.onHandshake?.(ws, message)
85
- } else {
87
+ } else if (handshakeReceived) {
86
88
  handlers.onMessage?.(message)
89
+ } else {
90
+ // Ignore messages before handshake - plugin will send full snapshot after
91
+ debug(
92
+ `Ignoring ${message.type} before handshake (conn ${connId})`
93
+ )
87
94
  }
88
95
  } catch (err) {
89
96
  error(`Failed to parse message:`, err)
@@ -103,6 +103,7 @@ export async function detectConflicts(
103
103
  const conflicts: Conflict[] = []
104
104
  const writes: FileInfo[] = []
105
105
  const localOnly: FileInfo[] = []
106
+ const unchanged: FileInfo[] = []
106
107
  const detect = options.detectConflicts ?? true
107
108
  const preferRemote = options.preferRemote ?? false
108
109
  const persistedState = options.persistedState
@@ -169,7 +170,12 @@ export async function detectConflicts(
169
170
  }
170
171
 
171
172
  if (local.content === remote.content) {
172
- // No change needed
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
+ })
173
179
  continue
174
180
  }
175
181
 
@@ -203,15 +209,19 @@ export async function detectConflicts(
203
209
  if (!processedFiles.has(localKey)) {
204
210
  const persisted = getPersistedState(local.name)
205
211
  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`)
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
+ )
209
218
  conflicts.push({
210
219
  fileName: local.name,
211
220
  localContent: local.content,
212
221
  remoteContent: null,
213
222
  localModifiedAt: local.modifiedAt,
214
223
  lastSyncedAt: persisted?.timestamp,
224
+ localClean,
215
225
  })
216
226
  } else {
217
227
  // New local file (never synced before): upload later
@@ -239,7 +249,7 @@ export async function detectConflicts(
239
249
  }
240
250
  }
241
251
 
242
- return { conflicts, writes, localOnly }
252
+ return { conflicts, writes, localOnly, unchanged }
243
253
  }
244
254
 
245
255
  export interface AutoResolveResult {
@@ -265,9 +275,22 @@ export function autoResolveConflicts(
265
275
  for (const conflict of conflicts) {
266
276
  const latestRemoteVersionMs = versionMap.get(conflict.fileName)
267
277
  const lastSyncedAt = conflict.lastSyncedAt
278
+ const localClean = conflict.localClean === true
268
279
 
269
280
  debug(`Auto-resolve checking ${conflict.fileName}`)
270
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
+
271
294
  if (!latestRemoteVersionMs) {
272
295
  debug(` No remote version data, keeping conflict`)
273
296
  remainingConflicts.push(conflict)
@@ -285,7 +308,7 @@ export function autoResolveConflicts(
285
308
 
286
309
  const remoteUnchanged =
287
310
  latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
288
- const localClean = conflict.localClean === true
311
+ // localClean already declared above for remote deletion handling
289
312
 
290
313
  if (remoteUnchanged && !localClean) {
291
314
  debug(` Remote unchanged, local changed -> LOCAL`)
@@ -1,9 +1,5 @@
1
1
  /**
2
- * Type installer helper
3
- *
4
- * Wraps @typescript/ata with our custom fetcher and initialization routines.
5
- * This preserves the battle-tested logic from the classic CLI while
6
- * conforming to the controller-centric architecture.
2
+ * Type installer helper using @typescript/ata
7
3
  */
8
4
 
9
5
  import { setupTypeAcquisition } from "@typescript/ata"
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi, afterEach } from "vitest"
2
+ import fs from "fs/promises"
3
+ import os from "os"
4
+ import path from "path"
5
+ import { initWatcher } from "./watcher.js"
6
+
7
+ const createdWatchers: any[] = []
8
+
9
+ vi.mock("chokidar", () => {
10
+ const createMockWatcher = () => {
11
+ const handlers: Record<string, Array<(filePath: string) => any>> = {
12
+ add: [],
13
+ change: [],
14
+ unlink: [],
15
+ }
16
+
17
+ return {
18
+ on(event: "add" | "change" | "unlink", handler: (file: string) => any) {
19
+ handlers[event]?.push(handler)
20
+ return this
21
+ },
22
+ async __emit(event: "add" | "change" | "unlink", filePath: string) {
23
+ for (const handler of handlers[event] ?? []) {
24
+ await handler(filePath)
25
+ }
26
+ },
27
+ close: vi.fn().mockResolvedValue(undefined),
28
+ }
29
+ }
30
+
31
+ const watch = vi.fn(() => {
32
+ const instance = createMockWatcher()
33
+ createdWatchers.push(instance)
34
+ return instance
35
+ })
36
+
37
+ return { default: { watch }, watch }
38
+ })
39
+
40
+ describe("initWatcher", () => {
41
+ afterEach(() => {
42
+ createdWatchers.length = 0
43
+ })
44
+
45
+ it("ignores unsupported extensions and sanitizes added files", async () => {
46
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-"))
47
+ const events: any[] = []
48
+ const watcher = initWatcher(tmpDir)
49
+ watcher.on("change", (event) => events.push(event))
50
+ const rawWatcher = createdWatchers.at(-1)!
51
+
52
+ const unsupportedPath = path.join(tmpDir, "note.txt")
53
+ await fs.writeFile(unsupportedPath, "hello", "utf-8")
54
+ await rawWatcher.__emit("add", unsupportedPath)
55
+ expect(events).toHaveLength(0)
56
+
57
+ const rawPath = path.join(tmpDir, "bad name!.tsx")
58
+ await fs.writeFile(rawPath, "export const X = 1;", "utf-8")
59
+ await rawWatcher.__emit("add", rawPath)
60
+
61
+ const addEvent = events.find((e) => e.kind === "add")
62
+ expect(addEvent).toBeDefined()
63
+ expect(addEvent?.relativePath).toBe("bad_name_.tsx")
64
+ expect(addEvent?.content).toContain("export const X")
65
+
66
+ const renamedPath = path.join(tmpDir, "bad_name_.tsx")
67
+ await expect(fs.readFile(renamedPath, "utf-8")).resolves.toContain(
68
+ "export const X"
69
+ )
70
+
71
+ await watcher.close()
72
+ await fs.rm(tmpDir, { recursive: true, force: true })
73
+ })
74
+ })
package/src/types.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * Core types for the controller-centric CLI architecture
3
3
  */
4
4
 
5
- import type { WebSocket } from "ws"
6
5
  import type { PendingDelete } from "@code-link/shared"
7
6
 
8
7
  // Configuration
@@ -30,6 +29,8 @@ export interface LocalFile {
30
29
  }
31
30
 
32
31
  // Conflict detection
32
+ // Deletions are represented by null content
33
+ // For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null
33
34
  export interface Conflict {
34
35
  fileName: string
35
36
  /** null means the file was deleted locally */
@@ -50,6 +51,7 @@ export interface ConflictResolution {
50
51
  conflicts: Conflict[]
51
52
  writes: FileInfo[]
52
53
  localOnly: FileInfo[]
54
+ unchanged: FileInfo[]
53
55
  }
54
56
 
55
57
  // Watcher events
@@ -107,3 +109,4 @@ export type OutgoingMessage =
107
109
  type: "conflict-version-request"
108
110
  conflicts: ConflictVersionRequest[]
109
111
  }
112
+ | { type: "sync-complete" }
@@ -121,7 +121,10 @@ export function info(message: string, ...args: unknown[]): void {
121
121
  */
122
122
  export function warn(message: string, ...args: unknown[]): void {
123
123
  if (currentLevel <= LogLevel.WARN) {
124
+ if (message === lastMessage) return // Skip exact duplicates silently
124
125
  flushDedupe()
126
+ lastMessage = message
127
+ lastMessageCount = 1
125
128
  console.warn(pc.yellow(`⚠ ${message}`), ...args)
126
129
  }
127
130
  }
@@ -1,53 +0,0 @@
1
- import fs from "fs/promises";
2
- import path from "path";
3
-
4
- //#region src/utils/project.ts
5
- function toPackageName(name) {
6
- return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
7
- }
8
- async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
9
- if (explicitDir) {
10
- const resolved = path.resolve(explicitDir);
11
- await fs.mkdir(path.join(resolved, "files"), { recursive: true });
12
- return resolved;
13
- }
14
- const cwd = process.cwd();
15
- const existing = await findExistingProjectDir(cwd, projectHash);
16
- if (existing) return existing;
17
- if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
18
- const dirName = toPackageName(projectName);
19
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
20
- await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
21
- const pkg = {
22
- name: dirName || projectHash,
23
- version: "1.0.0",
24
- private: true,
25
- framerProjectId: projectHash,
26
- framerProjectName: projectName
27
- };
28
- await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
29
- return projectDir;
30
- }
31
- async function findExistingProjectDir(baseDir, projectHash) {
32
- const candidate = path.join(baseDir, "package.json");
33
- if (await matchesProject(candidate, projectHash)) return baseDir;
34
- const entries = await fs.readdir(baseDir, { withFileTypes: true });
35
- for (const entry of entries) {
36
- if (!entry.isDirectory()) continue;
37
- const dir = path.join(baseDir, entry.name);
38
- if (await matchesProject(path.join(dir, "package.json"), projectHash)) return dir;
39
- }
40
- return null;
41
- }
42
- async function matchesProject(packageJsonPath, projectHash) {
43
- try {
44
- const content = await fs.readFile(packageJsonPath, "utf-8");
45
- const pkg = JSON.parse(content);
46
- return pkg.framerProjectId === projectHash;
47
- } catch {
48
- return false;
49
- }
50
- }
51
-
52
- //#endregion
53
- export { findOrCreateProjectDir };