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.
- package/dist/index.js +551 -144
- package/package.json +12 -12
- package/src/controller.test.ts +22 -137
- package/src/controller.ts +242 -106
- package/src/helpers/connection.ts +10 -10
- package/src/helpers/files.ts +99 -37
- package/src/helpers/installer.ts +11 -11
- package/src/helpers/user-actions.ts +7 -11
- package/src/helpers/watcher.ts +4 -9
- package/src/index.ts +7 -4
- package/src/types.ts +4 -2
- package/src/utils/{hashing.ts → hash-tracker.ts} +1 -17
- package/src/utils/logging.ts +191 -6
- package/src/utils/project.ts +15 -8
|
@@ -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,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
|
-
|
|
71
|
+
debug(`WebSocket server listening on port ${port}`)
|
|
72
72
|
|
|
73
73
|
wss.on("connection", (ws: WebSocket) => {
|
|
74
74
|
const connId = ++connectionId
|
|
75
|
-
|
|
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
|
-
|
|
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(`
|
|
89
|
+
error(`Failed to parse message:`, err)
|
|
90
90
|
}
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
ws.on("close", (code, reason) => {
|
|
94
|
-
|
|
95
|
-
`
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
debug(`Send error for ${message.type}: ${err.message}`)
|
|
174
174
|
resolve(false)
|
|
175
175
|
} else {
|
|
176
176
|
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
|
*/
|
|
@@ -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(
|
|
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
|
|
118
|
-
|
|
136
|
+
const normalizedKey = normalizeForComparison(normalized.relativePath)
|
|
137
|
+
const local = localFileMap.get(normalizedKey)
|
|
138
|
+
processedFiles.add(normalizedKey)
|
|
119
139
|
|
|
120
|
-
const persisted =
|
|
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
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
269
|
+
debug(`Auto-resolve checking ${conflict.fileName}`)
|
|
204
270
|
|
|
205
271
|
if (!latestRemoteVersionMs) {
|
|
206
|
-
|
|
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
|
-
|
|
278
|
+
debug(` No last sync timestamp, keeping conflict`)
|
|
213
279
|
remainingConflicts.push(conflict)
|
|
214
280
|
continue
|
|
215
281
|
}
|
|
216
282
|
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
291
|
+
debug(` Remote unchanged, local changed -> LOCAL`)
|
|
230
292
|
autoResolvedLocal.push(conflict)
|
|
231
293
|
} else if (localClean && !remoteUnchanged) {
|
|
232
|
-
|
|
294
|
+
debug(` Local unchanged, remote changed -> REMOTE`)
|
|
233
295
|
autoResolvedRemote.push(conflict)
|
|
234
296
|
} else if (remoteUnchanged && localClean) {
|
|
235
|
-
|
|
297
|
+
debug(` Both unchanged, skipping`)
|
|
236
298
|
} else {
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
debug(`File already deleted: ${normalized.relativePath}`)
|
|
313
375
|
return
|
|
314
376
|
}
|
|
315
377
|
|
package/src/helpers/installer.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
+
debug("📦 React types (from cache)")
|
|
394
394
|
} else {
|
|
395
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
}
|