framer-code-link 0.1.3 → 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/README.md +1 -1
- package/dist/{index.js → index.mjs} +703 -260
- package/package.json +12 -12
- package/src/controller.test.ts +63 -138
- package/src/controller.ts +296 -109
- package/src/helpers/connection.ts +18 -11
- package/src/helpers/files.ts +125 -40
- package/src/helpers/installer.ts +12 -16
- package/src/helpers/user-actions.ts +7 -11
- package/src/helpers/watcher.test.ts +74 -0
- package/src/helpers/watcher.ts +4 -9
- package/src/index.ts +7 -4
- package/src/types.ts +8 -3
- package/src/utils/{hashing.ts → hash-tracker.ts} +1 -17
- package/src/utils/logging.ts +194 -6
- package/src/utils/project.ts +15 -8
- package/dist/project-DhpsFg77.js +0 -53
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { WebSocketServer, WebSocket } from "ws"
|
|
9
9
|
import type { IncomingMessage, OutgoingMessage } from "../types.js"
|
|
10
|
-
import {
|
|
10
|
+
import { debug, error } from "../utils/logging.js"
|
|
11
11
|
|
|
12
12
|
export interface ConnectionCallbacks {
|
|
13
13
|
onHandshake: (
|
|
@@ -68,11 +68,12 @@ 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
|
-
|
|
71
|
+
debug(`WebSocket server listening on port ${port}`)
|
|
72
72
|
|
|
73
73
|
wss.on("connection", (ws: WebSocket) => {
|
|
74
74
|
const connId = ++connectionId
|
|
75
|
-
|
|
75
|
+
let handshakeReceived = false
|
|
76
|
+
debug(`Client connected (conn ${connId})`)
|
|
76
77
|
|
|
77
78
|
ws.on("message", (data: Buffer) => {
|
|
78
79
|
try {
|
|
@@ -80,25 +81,31 @@ export function initConnection(port: number): Promise<Connection> {
|
|
|
80
81
|
|
|
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
|
-
error(`
|
|
96
|
+
error(`Failed to parse message:`, err)
|
|
90
97
|
}
|
|
91
98
|
})
|
|
92
99
|
|
|
93
100
|
ws.on("close", (code, reason) => {
|
|
94
|
-
|
|
95
|
-
`
|
|
101
|
+
debug(
|
|
102
|
+
`Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
|
|
96
103
|
)
|
|
97
104
|
handlers.onDisconnect?.()
|
|
98
105
|
})
|
|
99
106
|
|
|
100
107
|
ws.on("error", (err) => {
|
|
101
|
-
error(`
|
|
108
|
+
error(`WebSocket error:`, err)
|
|
102
109
|
})
|
|
103
110
|
})
|
|
104
111
|
|
|
@@ -163,14 +170,14 @@ export function sendMessage(
|
|
|
163
170
|
// Check socket state before attempting to send
|
|
164
171
|
if (socket.readyState !== READY_STATE.OPEN) {
|
|
165
172
|
const stateStr = readyStateToString(socket.readyState)
|
|
166
|
-
|
|
173
|
+
debug(`Cannot send ${message.type}: socket is ${stateStr}`)
|
|
167
174
|
resolve(false)
|
|
168
175
|
return
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
socket.send(JSON.stringify(message), (err) => {
|
|
172
179
|
if (err) {
|
|
173
|
-
|
|
180
|
+
debug(`Send error for ${message.type}: ${err.message}`)
|
|
174
181
|
resolve(false)
|
|
175
182
|
} else {
|
|
176
183
|
resolve(true)
|
package/src/helpers/files.ts
CHANGED
|
@@ -18,9 +18,9 @@ import type {
|
|
|
18
18
|
Conflict,
|
|
19
19
|
ConflictVersionData,
|
|
20
20
|
} from "../types.js"
|
|
21
|
-
import type { HashTracker } from "../utils/
|
|
21
|
+
import type { HashTracker } from "../utils/hash-tracker.js"
|
|
22
22
|
import { normalizePath, sanitizeFilePath } from "@code-link/shared"
|
|
23
|
-
import {
|
|
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
|
*/
|
|
@@ -98,42 +103,79 @@ export async function detectConflicts(
|
|
|
98
103
|
const conflicts: Conflict[] = []
|
|
99
104
|
const writes: FileInfo[] = []
|
|
100
105
|
const localOnly: FileInfo[] = []
|
|
106
|
+
const unchanged: FileInfo[] = []
|
|
101
107
|
const detect = options.detectConflicts ?? true
|
|
102
108
|
const preferRemote = options.preferRemote ?? false
|
|
103
109
|
const persistedState = options.persistedState
|
|
104
110
|
|
|
111
|
+
const getPersistedState = (fileName: string) =>
|
|
112
|
+
persistedState?.get(normalizeForComparison(fileName)) ??
|
|
113
|
+
persistedState?.get(fileName)
|
|
114
|
+
|
|
105
115
|
debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
|
|
106
116
|
|
|
107
|
-
// Build a snapshot of all local files
|
|
117
|
+
// Build a snapshot of all local files (keyed by lowercase for case-insensitive matching)
|
|
108
118
|
const localFiles = await listFiles(filesDir)
|
|
109
|
-
const localFileMap = new Map(
|
|
119
|
+
const localFileMap = new Map(
|
|
120
|
+
localFiles.map((f) => [normalizeForComparison(f.name), f])
|
|
121
|
+
)
|
|
110
122
|
|
|
111
|
-
//
|
|
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)
|
|
112
132
|
const processedFiles = new Set<string>()
|
|
113
133
|
|
|
114
134
|
// Process remote files (remote-only or both sides)
|
|
115
135
|
for (const remote of remoteFiles) {
|
|
116
136
|
const normalized = resolveRemoteReference(filesDir, remote.name)
|
|
117
|
-
const
|
|
118
|
-
|
|
137
|
+
const normalizedKey = normalizeForComparison(normalized.relativePath)
|
|
138
|
+
const local = localFileMap.get(normalizedKey)
|
|
139
|
+
processedFiles.add(normalizedKey)
|
|
119
140
|
|
|
120
|
-
const persisted =
|
|
141
|
+
const persisted = getPersistedState(normalized.relativePath)
|
|
121
142
|
const localHash = local ? hashFileContent(local.content) : null
|
|
122
143
|
const localMatchesPersisted =
|
|
123
144
|
!!persisted && !!local && localHash === persisted.contentHash
|
|
124
145
|
|
|
125
146
|
if (!local) {
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
169
|
continue
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
if (local.content === remote.content) {
|
|
136
|
-
//
|
|
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
|
+
})
|
|
137
179
|
continue
|
|
138
180
|
}
|
|
139
181
|
|
|
@@ -163,17 +205,51 @@ export async function detectConflicts(
|
|
|
163
205
|
|
|
164
206
|
// Process local-only files (not present in remote)
|
|
165
207
|
for (const local of localFiles) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
|
173
249
|
}
|
|
174
250
|
}
|
|
175
251
|
|
|
176
|
-
return { conflicts, writes, localOnly }
|
|
252
|
+
return { conflicts, writes, localOnly, unchanged }
|
|
177
253
|
}
|
|
178
254
|
|
|
179
255
|
export interface AutoResolveResult {
|
|
@@ -199,42 +275,51 @@ export function autoResolveConflicts(
|
|
|
199
275
|
for (const conflict of conflicts) {
|
|
200
276
|
const latestRemoteVersionMs = versionMap.get(conflict.fileName)
|
|
201
277
|
const lastSyncedAt = conflict.lastSyncedAt
|
|
278
|
+
const localClean = conflict.localClean === true
|
|
279
|
+
|
|
280
|
+
debug(`Auto-resolve checking ${conflict.fileName}`)
|
|
202
281
|
|
|
203
|
-
|
|
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
|
+
}
|
|
204
293
|
|
|
205
294
|
if (!latestRemoteVersionMs) {
|
|
206
|
-
|
|
295
|
+
debug(` No remote version data, keeping conflict`)
|
|
207
296
|
remainingConflicts.push(conflict)
|
|
208
297
|
continue
|
|
209
298
|
}
|
|
210
299
|
|
|
211
300
|
if (!lastSyncedAt) {
|
|
212
|
-
|
|
301
|
+
debug(` No last sync timestamp, keeping conflict`)
|
|
213
302
|
remainingConflicts.push(conflict)
|
|
214
303
|
continue
|
|
215
304
|
}
|
|
216
305
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
)
|
|
220
|
-
info(
|
|
221
|
-
`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`
|
|
222
|
-
)
|
|
306
|
+
debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`)
|
|
307
|
+
debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`)
|
|
223
308
|
|
|
224
309
|
const remoteUnchanged =
|
|
225
310
|
latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
|
|
226
|
-
|
|
311
|
+
// localClean already declared above for remote deletion handling
|
|
227
312
|
|
|
228
313
|
if (remoteUnchanged && !localClean) {
|
|
229
|
-
|
|
314
|
+
debug(` Remote unchanged, local changed -> LOCAL`)
|
|
230
315
|
autoResolvedLocal.push(conflict)
|
|
231
316
|
} else if (localClean && !remoteUnchanged) {
|
|
232
|
-
|
|
317
|
+
debug(` Local unchanged, remote changed -> REMOTE`)
|
|
233
318
|
autoResolvedRemote.push(conflict)
|
|
234
319
|
} else if (remoteUnchanged && localClean) {
|
|
235
|
-
|
|
320
|
+
debug(` Both unchanged, skipping`)
|
|
236
321
|
} else {
|
|
237
|
-
|
|
322
|
+
debug(` Both changed, real conflict`)
|
|
238
323
|
remainingConflicts.push(conflict)
|
|
239
324
|
}
|
|
240
325
|
}
|
|
@@ -256,7 +341,7 @@ export async function writeRemoteFiles(
|
|
|
256
341
|
hashTracker: HashTracker,
|
|
257
342
|
installer?: { process: (fileName: string, content: string) => void }
|
|
258
343
|
): Promise<void> {
|
|
259
|
-
|
|
344
|
+
debug(`Writing ${files.length} remote files`)
|
|
260
345
|
|
|
261
346
|
for (const file of files) {
|
|
262
347
|
try {
|
|
@@ -302,14 +387,14 @@ export async function deleteLocalFile(
|
|
|
302
387
|
// Clear the hash immediately
|
|
303
388
|
hashTracker.forget(normalized.relativePath)
|
|
304
389
|
|
|
305
|
-
|
|
390
|
+
debug(`Deleted file: ${normalized.relativePath}`)
|
|
306
391
|
} catch (err) {
|
|
307
392
|
const nodeError = err as NodeJS.ErrnoException
|
|
308
393
|
|
|
309
394
|
if (nodeError?.code === "ENOENT") {
|
|
310
395
|
// Treat missing files as already deleted to keep hash tracker in sync
|
|
311
396
|
hashTracker.forget(normalized.relativePath)
|
|
312
|
-
|
|
397
|
+
debug(`File already deleted: ${normalized.relativePath}`)
|
|
313
398
|
return
|
|
314
399
|
}
|
|
315
400
|
|
package/src/helpers/installer.ts
CHANGED
|
@@ -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"
|
|
@@ -11,7 +7,7 @@ import ts from "typescript"
|
|
|
11
7
|
import path from "path"
|
|
12
8
|
import fs from "fs/promises"
|
|
13
9
|
import { extractImports } from "../utils/imports.js"
|
|
14
|
-
import {
|
|
10
|
+
import { debug, warn } from "../utils/logging.js"
|
|
15
11
|
|
|
16
12
|
export interface InstallerConfig {
|
|
17
13
|
projectDir: string
|
|
@@ -85,7 +81,7 @@ export class Installer {
|
|
|
85
81
|
|
|
86
82
|
if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
|
|
87
83
|
seenPackages.add(pkgMatch[1])
|
|
88
|
-
|
|
84
|
+
debug(`📦 Types: ${pkgMatch[1]}`)
|
|
89
85
|
}
|
|
90
86
|
|
|
91
87
|
await this.writeTypeFile(receivedPath, code)
|
|
@@ -93,7 +89,7 @@ export class Installer {
|
|
|
93
89
|
},
|
|
94
90
|
})
|
|
95
91
|
|
|
96
|
-
|
|
92
|
+
debug("Type installer initialized")
|
|
97
93
|
}
|
|
98
94
|
|
|
99
95
|
/**
|
|
@@ -181,7 +177,7 @@ export class Installer {
|
|
|
181
177
|
}
|
|
182
178
|
|
|
183
179
|
this.processedImports.add(hash)
|
|
184
|
-
|
|
180
|
+
debug(`Processing imports for ${fileName} (${imports.length} packages)`)
|
|
185
181
|
|
|
186
182
|
try {
|
|
187
183
|
await this.ata(content)
|
|
@@ -318,7 +314,7 @@ export class Installer {
|
|
|
318
314
|
include: ["files/**/*", "framer-modules.d.ts"],
|
|
319
315
|
}
|
|
320
316
|
await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2))
|
|
321
|
-
|
|
317
|
+
debug("Created tsconfig.json")
|
|
322
318
|
}
|
|
323
319
|
}
|
|
324
320
|
|
|
@@ -334,7 +330,7 @@ export class Installer {
|
|
|
334
330
|
trailingComma: "es5",
|
|
335
331
|
}
|
|
336
332
|
await fs.writeFile(prettierPath, JSON.stringify(config, null, 2))
|
|
337
|
-
|
|
333
|
+
debug("Created .prettierrc")
|
|
338
334
|
}
|
|
339
335
|
}
|
|
340
336
|
|
|
@@ -352,7 +348,7 @@ declare module "https://framerusercontent.com/*"
|
|
|
352
348
|
declare module "*.json"
|
|
353
349
|
`
|
|
354
350
|
await fs.writeFile(declarationsPath, declarations)
|
|
355
|
-
|
|
351
|
+
debug("Created framer-modules.d.ts")
|
|
356
352
|
}
|
|
357
353
|
}
|
|
358
354
|
|
|
@@ -369,7 +365,7 @@ declare module "*.json"
|
|
|
369
365
|
description: "Framer files synced with framer-code-link",
|
|
370
366
|
}
|
|
371
367
|
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2))
|
|
372
|
-
|
|
368
|
+
debug("Created package.json")
|
|
373
369
|
}
|
|
374
370
|
}
|
|
375
371
|
|
|
@@ -390,9 +386,9 @@ declare module "*.json"
|
|
|
390
386
|
if (
|
|
391
387
|
await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)
|
|
392
388
|
) {
|
|
393
|
-
|
|
389
|
+
debug("📦 React types (from cache)")
|
|
394
390
|
} else {
|
|
395
|
-
|
|
391
|
+
debug("Downloading React 18 types...")
|
|
396
392
|
await this.downloadTypePackage(
|
|
397
393
|
"@types/react",
|
|
398
394
|
REACT_TYPES_VERSION,
|
|
@@ -415,7 +411,7 @@ declare module "*.json"
|
|
|
415
411
|
reactDomFiles
|
|
416
412
|
)
|
|
417
413
|
) {
|
|
418
|
-
|
|
414
|
+
debug("📦 React DOM types (from cache)")
|
|
419
415
|
} else {
|
|
420
416
|
await this.downloadTypePackage(
|
|
421
417
|
"@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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
debug(`Cancelled pending action: ${actionId}`)
|
|
159
155
|
}
|
|
160
156
|
this.pendingActions.clear()
|
|
161
157
|
}
|
|
@@ -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/helpers/watcher.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
sanitizeFilePath,
|
|
17
17
|
} from "@code-link/shared"
|
|
18
18
|
import { getRelativePath } from "../utils/paths.js"
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
14
|
-
import { getPortFromHash } from "
|
|
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
|
-
|
|
92
|
-
"
|
|
94
|
+
warn(
|
|
95
|
+
"Auto-delete mode enabled - files will be deleted without confirmation"
|
|
93
96
|
)
|
|
94
97
|
}
|
|
95
98
|
|