framer-code-link 0.1.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 +196 -0
- package/dist/index.js +2021 -0
- package/dist/project-DhpsFg77.js +53 -0
- package/package.json +36 -0
- package/src/controller.test.ts +966 -0
- package/src/controller.ts +1212 -0
- package/src/helpers/connection.ts +95 -0
- package/src/helpers/files.test.ts +117 -0
- package/src/helpers/files.ts +378 -0
- package/src/helpers/installer.ts +534 -0
- package/src/helpers/sync-validator.ts +87 -0
- package/src/helpers/user-actions.ts +162 -0
- package/src/helpers/watcher.ts +115 -0
- package/src/index.ts +75 -0
- package/src/types.ts +107 -0
- package/src/utils/file-metadata-cache.ts +121 -0
- package/src/utils/hashing.ts +95 -0
- package/src/utils/imports.ts +62 -0
- package/src/utils/logging.ts +47 -0
- package/src/utils/paths.ts +76 -0
- package/src/utils/project.ts +94 -0
- package/src/utils/state-persistence.ts +138 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection helper
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around ws.Server that normalizes handshake and surfaces
|
|
5
|
+
* simple callbacks. Keeps raw socket API localized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WebSocketServer, WebSocket } from "ws"
|
|
9
|
+
import type { IncomingMessage, OutgoingMessage } from "../types.js"
|
|
10
|
+
import { info, error } from "../utils/logging.js"
|
|
11
|
+
|
|
12
|
+
export interface ConnectionCallbacks {
|
|
13
|
+
onHandshake: (
|
|
14
|
+
client: WebSocket,
|
|
15
|
+
message: { projectId: string; projectName: string }
|
|
16
|
+
) => void
|
|
17
|
+
onMessage: (message: IncomingMessage) => void
|
|
18
|
+
onDisconnect: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Connection {
|
|
22
|
+
on(event: "handshake", handler: ConnectionCallbacks["onHandshake"]): void
|
|
23
|
+
on(event: "message", handler: ConnectionCallbacks["onMessage"]): void
|
|
24
|
+
on(event: "disconnect", handler: ConnectionCallbacks["onDisconnect"]): void
|
|
25
|
+
close(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initializes a WebSocket server and returns a connection interface
|
|
30
|
+
*/
|
|
31
|
+
export function initConnection(port: number): Connection {
|
|
32
|
+
const wss = new WebSocketServer({ port })
|
|
33
|
+
const handlers: Partial<ConnectionCallbacks> = {}
|
|
34
|
+
|
|
35
|
+
info(`WebSocket server listening on port ${port}`)
|
|
36
|
+
|
|
37
|
+
wss.on("connection", (ws: WebSocket) => {
|
|
38
|
+
info("Client connected")
|
|
39
|
+
|
|
40
|
+
ws.on("message", (data: Buffer) => {
|
|
41
|
+
try {
|
|
42
|
+
const message = JSON.parse(data.toString()) as IncomingMessage
|
|
43
|
+
|
|
44
|
+
// Special handling for handshake
|
|
45
|
+
if (message.type === "handshake") {
|
|
46
|
+
handlers.onHandshake?.(ws, message)
|
|
47
|
+
} else {
|
|
48
|
+
handlers.onMessage?.(message)
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
error("Failed to parse message:", err)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
ws.on("close", () => {
|
|
56
|
+
info("Client disconnected")
|
|
57
|
+
handlers.onDisconnect?.()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
ws.on("error", (err) => {
|
|
61
|
+
error("WebSocket error:", err)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
on(event: "handshake" | "message" | "disconnect", handler: any): void {
|
|
67
|
+
if (event === "handshake") {
|
|
68
|
+
handlers.onHandshake = handler
|
|
69
|
+
} else if (event === "message") {
|
|
70
|
+
handlers.onMessage = handler
|
|
71
|
+
} else if (event === "disconnect") {
|
|
72
|
+
handlers.onDisconnect = handler
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
close(): void {
|
|
77
|
+
wss.close()
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sends a message to a connected socket
|
|
84
|
+
*/
|
|
85
|
+
export function sendMessage(
|
|
86
|
+
socket: WebSocket,
|
|
87
|
+
message: OutgoingMessage
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
socket.send(JSON.stringify(message), (err) => {
|
|
91
|
+
if (err) reject(err)
|
|
92
|
+
else resolve()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { describe, it, expect } from "vitest"
|
|
5
|
+
import { autoResolveConflicts, detectConflicts } from "./files.js"
|
|
6
|
+
import type { Conflict } from "../types.js"
|
|
7
|
+
import { hashFileContent } from "../utils/state-persistence.js"
|
|
8
|
+
|
|
9
|
+
function makeConflict(overrides: Partial<Conflict> = {}): Conflict {
|
|
10
|
+
return {
|
|
11
|
+
fileName: overrides.fileName ?? "Test.tsx",
|
|
12
|
+
localContent: overrides.localContent ?? "local",
|
|
13
|
+
remoteContent: overrides.remoteContent ?? "remote",
|
|
14
|
+
localModifiedAt: overrides.localModifiedAt ?? Date.now(),
|
|
15
|
+
remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(),
|
|
16
|
+
lastSyncedAt: overrides.lastSyncedAt ?? Date.now(),
|
|
17
|
+
localClean: overrides.localClean,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("autoResolveConflicts", () => {
|
|
22
|
+
it("classifies conflicts as local when remote unchanged and local changed", () => {
|
|
23
|
+
const conflict = makeConflict({
|
|
24
|
+
lastSyncedAt: 5_000,
|
|
25
|
+
localClean: false,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const result = autoResolveConflicts(
|
|
29
|
+
[conflict],
|
|
30
|
+
[{ fileName: conflict.fileName, latestRemoteVersionMs: 5_000 }]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
expect(result.autoResolvedLocal).toHaveLength(1)
|
|
34
|
+
expect(result.autoResolvedRemote).toHaveLength(0)
|
|
35
|
+
expect(result.remainingConflicts).toHaveLength(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("classifies conflicts as remote when local is clean and remote changed", () => {
|
|
39
|
+
const conflict = makeConflict({
|
|
40
|
+
lastSyncedAt: 5_000,
|
|
41
|
+
localClean: true,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const result = autoResolveConflicts(
|
|
45
|
+
[conflict],
|
|
46
|
+
[{ fileName: conflict.fileName, latestRemoteVersionMs: 10_000 }]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
expect(result.autoResolvedRemote).toHaveLength(1)
|
|
50
|
+
expect(result.autoResolvedLocal).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("keeps conflicts that have both sides changed", () => {
|
|
54
|
+
const conflict = makeConflict({
|
|
55
|
+
lastSyncedAt: 5_000,
|
|
56
|
+
localClean: false,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const result = autoResolveConflicts(
|
|
60
|
+
[conflict],
|
|
61
|
+
[{ fileName: conflict.fileName, latestRemoteVersionMs: 7_500 }]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(result.remainingConflicts).toHaveLength(1)
|
|
65
|
+
expect(result.autoResolvedLocal).toHaveLength(0)
|
|
66
|
+
expect(result.autoResolvedRemote).toHaveLength(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("keeps conflicts when version data is missing", () => {
|
|
70
|
+
const conflict = makeConflict({
|
|
71
|
+
lastSyncedAt: 5_000,
|
|
72
|
+
localClean: true,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const result = autoResolveConflicts([conflict], [])
|
|
76
|
+
|
|
77
|
+
expect(result.remainingConflicts).toHaveLength(1)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("detectConflicts", () => {
|
|
82
|
+
it("marks conflicts as localClean when local matches persisted state", async () => {
|
|
83
|
+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-"))
|
|
84
|
+
try {
|
|
85
|
+
const filesDir = path.join(tmpRoot, "files")
|
|
86
|
+
await fs.mkdir(filesDir, { recursive: true })
|
|
87
|
+
|
|
88
|
+
const localContent = "local content"
|
|
89
|
+
await fs.writeFile(path.join(filesDir, "Test.tsx"), localContent, "utf-8")
|
|
90
|
+
|
|
91
|
+
const persistedState = new Map([
|
|
92
|
+
[
|
|
93
|
+
"Test.tsx",
|
|
94
|
+
{ contentHash: hashFileContent(localContent), timestamp: 1_000 },
|
|
95
|
+
],
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
const result = await detectConflicts(
|
|
99
|
+
[
|
|
100
|
+
{
|
|
101
|
+
name: "Test.tsx",
|
|
102
|
+
content: "remote content",
|
|
103
|
+
modifiedAt: 2_000,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
filesDir,
|
|
107
|
+
{ persistedState }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(result.writes).toHaveLength(0)
|
|
111
|
+
expect(result.conflicts).toHaveLength(1)
|
|
112
|
+
expect(result.conflicts[0]?.localClean).toBe(true)
|
|
113
|
+
} finally {
|
|
114
|
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,378 @@
|
|
|
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/hashing.js"
|
|
22
|
+
import { normalizePath, sanitizeFilePath } from "@code-link/shared"
|
|
23
|
+
import { info, 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
|
+
/**
|
|
34
|
+
* Lists all supported files in the files directory
|
|
35
|
+
*/
|
|
36
|
+
export async function listFiles(filesDir: string): Promise<FileInfo[]> {
|
|
37
|
+
const files: FileInfo[] = []
|
|
38
|
+
|
|
39
|
+
async function walk(currentDir: string): Promise<void> {
|
|
40
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true })
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const entryPath = path.join(currentDir, entry.name)
|
|
44
|
+
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
await walk(entryPath)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isSupportedExtension(entry.name)) continue
|
|
51
|
+
|
|
52
|
+
const relativePath = path.relative(filesDir, entryPath)
|
|
53
|
+
const normalizedPath = normalizePath(relativePath)
|
|
54
|
+
// Don't capitalize when listing existing files - preserve their actual names
|
|
55
|
+
const sanitizedPath = sanitizeFilePath(normalizedPath, false).path
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const [content, stats] = await Promise.all([
|
|
59
|
+
fs.readFile(entryPath, "utf-8"),
|
|
60
|
+
fs.stat(entryPath),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
files.push({
|
|
64
|
+
name: sanitizedPath,
|
|
65
|
+
content,
|
|
66
|
+
modifiedAt: stats.mtimeMs,
|
|
67
|
+
})
|
|
68
|
+
} catch (err) {
|
|
69
|
+
warn(`Failed to read ${entryPath}:`, err)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await walk(filesDir)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
warn("Failed to list files:", err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return files
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detects conflicts between remote files and local filesystem
|
|
85
|
+
* Returns conflicts that need user resolution and safe writes that can be applied
|
|
86
|
+
*/
|
|
87
|
+
export interface ConflictDetectionOptions {
|
|
88
|
+
preferRemote?: boolean
|
|
89
|
+
detectConflicts?: boolean
|
|
90
|
+
persistedState?: Map<string, PersistedFileState>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function detectConflicts(
|
|
94
|
+
remoteFiles: FileInfo[],
|
|
95
|
+
filesDir: string,
|
|
96
|
+
options: ConflictDetectionOptions = {}
|
|
97
|
+
): Promise<ConflictResolution> {
|
|
98
|
+
const conflicts: Conflict[] = []
|
|
99
|
+
const writes: FileInfo[] = []
|
|
100
|
+
const localOnly: FileInfo[] = []
|
|
101
|
+
const detect = options.detectConflicts ?? true
|
|
102
|
+
const preferRemote = options.preferRemote ?? false
|
|
103
|
+
const persistedState = options.persistedState
|
|
104
|
+
|
|
105
|
+
debug(`Detecting conflicts for ${remoteFiles.length} remote files`)
|
|
106
|
+
|
|
107
|
+
// Build a snapshot of all local files
|
|
108
|
+
const localFiles = await listFiles(filesDir)
|
|
109
|
+
const localFileMap = new Map(localFiles.map((f) => [f.name, f]))
|
|
110
|
+
|
|
111
|
+
// Track which files we've processed
|
|
112
|
+
const processedFiles = new Set<string>()
|
|
113
|
+
|
|
114
|
+
// Process remote files (remote-only or both sides)
|
|
115
|
+
for (const remote of remoteFiles) {
|
|
116
|
+
const normalized = resolveRemoteReference(filesDir, remote.name)
|
|
117
|
+
const local = localFileMap.get(normalized.relativePath)
|
|
118
|
+
processedFiles.add(normalized.relativePath)
|
|
119
|
+
|
|
120
|
+
const persisted = persistedState?.get(normalized.relativePath)
|
|
121
|
+
const localHash = local ? hashFileContent(local.content) : null
|
|
122
|
+
const localMatchesPersisted =
|
|
123
|
+
!!persisted && !!local && localHash === persisted.contentHash
|
|
124
|
+
|
|
125
|
+
if (!local) {
|
|
126
|
+
// Remote-only: download
|
|
127
|
+
writes.push({
|
|
128
|
+
name: normalized.relativePath,
|
|
129
|
+
content: remote.content,
|
|
130
|
+
modifiedAt: remote.modifiedAt,
|
|
131
|
+
})
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (local.content === remote.content) {
|
|
136
|
+
// No change needed
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!detect || preferRemote) {
|
|
141
|
+
writes.push({
|
|
142
|
+
name: normalized.relativePath,
|
|
143
|
+
content: remote.content,
|
|
144
|
+
modifiedAt: remote.modifiedAt,
|
|
145
|
+
})
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if local file is "clean" (matches last persisted state)
|
|
150
|
+
// If so, we can safely overwrite it with remote changes
|
|
151
|
+
// Both sides have the file with different content -> conflict
|
|
152
|
+
const localClean = persisted ? localMatchesPersisted : undefined
|
|
153
|
+
conflicts.push({
|
|
154
|
+
fileName: normalized.relativePath,
|
|
155
|
+
localContent: local.content,
|
|
156
|
+
remoteContent: remote.content,
|
|
157
|
+
localModifiedAt: local.modifiedAt,
|
|
158
|
+
remoteModifiedAt: remote.modifiedAt,
|
|
159
|
+
lastSyncedAt: persisted?.timestamp,
|
|
160
|
+
localClean,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Process local-only files (not present in remote)
|
|
165
|
+
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
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { conflicts, writes, localOnly }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface AutoResolveResult {
|
|
180
|
+
autoResolvedLocal: Conflict[]
|
|
181
|
+
autoResolvedRemote: Conflict[]
|
|
182
|
+
remainingConflicts: Conflict[]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function autoResolveConflicts(
|
|
186
|
+
conflicts: Conflict[],
|
|
187
|
+
versions: ConflictVersionData[],
|
|
188
|
+
options: { remoteDriftMs?: number } = {}
|
|
189
|
+
): AutoResolveResult {
|
|
190
|
+
const versionMap = new Map(
|
|
191
|
+
versions.map((version) => [version.fileName, version.latestRemoteVersionMs])
|
|
192
|
+
)
|
|
193
|
+
const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS
|
|
194
|
+
|
|
195
|
+
const autoResolvedLocal: Conflict[] = []
|
|
196
|
+
const autoResolvedRemote: Conflict[] = []
|
|
197
|
+
const remainingConflicts: Conflict[] = []
|
|
198
|
+
|
|
199
|
+
for (const conflict of conflicts) {
|
|
200
|
+
const latestRemoteVersionMs = versionMap.get(conflict.fileName)
|
|
201
|
+
const lastSyncedAt = conflict.lastSyncedAt
|
|
202
|
+
|
|
203
|
+
info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`)
|
|
204
|
+
|
|
205
|
+
if (!latestRemoteVersionMs) {
|
|
206
|
+
info(`-> No remote version data, keeping conflict`)
|
|
207
|
+
remainingConflicts.push(conflict)
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!lastSyncedAt) {
|
|
212
|
+
info(`-> No last sync timestamp, keeping conflict`)
|
|
213
|
+
remainingConflicts.push(conflict)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
info(
|
|
218
|
+
`-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`
|
|
219
|
+
)
|
|
220
|
+
info(
|
|
221
|
+
`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const remoteUnchanged =
|
|
225
|
+
latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
|
|
226
|
+
const localClean = conflict.localClean === true
|
|
227
|
+
|
|
228
|
+
if (remoteUnchanged && !localClean) {
|
|
229
|
+
info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`)
|
|
230
|
+
autoResolvedLocal.push(conflict)
|
|
231
|
+
} else if (localClean && !remoteUnchanged) {
|
|
232
|
+
info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`)
|
|
233
|
+
autoResolvedRemote.push(conflict)
|
|
234
|
+
} else if (remoteUnchanged && localClean) {
|
|
235
|
+
info(` -> Both unchanged. Skipping (no conflict).`)
|
|
236
|
+
} else {
|
|
237
|
+
info(` -> Both sides changed. Real conflict.`)
|
|
238
|
+
remainingConflicts.push(conflict)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
autoResolvedLocal,
|
|
244
|
+
autoResolvedRemote,
|
|
245
|
+
remainingConflicts,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
251
|
+
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
252
|
+
*/
|
|
253
|
+
export async function writeRemoteFiles(
|
|
254
|
+
files: FileInfo[],
|
|
255
|
+
filesDir: string,
|
|
256
|
+
hashTracker: HashTracker,
|
|
257
|
+
installer?: { process: (fileName: string, content: string) => void }
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
info(`Writing ${files.length} remote files`)
|
|
260
|
+
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
try {
|
|
263
|
+
const normalized = resolveRemoteReference(filesDir, file.name)
|
|
264
|
+
const fullPath = normalized.absolutePath
|
|
265
|
+
|
|
266
|
+
// Ensure directory exists
|
|
267
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
|
268
|
+
|
|
269
|
+
// CRITICAL ORDER: Update hash tracker FIRST (in memory)
|
|
270
|
+
hashTracker.remember(normalized.relativePath, file.content)
|
|
271
|
+
|
|
272
|
+
// THEN write to disk
|
|
273
|
+
await fs.writeFile(fullPath, file.content, "utf-8")
|
|
274
|
+
|
|
275
|
+
debug(`Wrote file: ${normalized.relativePath}`)
|
|
276
|
+
|
|
277
|
+
// Trigger type installer if available
|
|
278
|
+
installer?.process(normalized.relativePath, file.content)
|
|
279
|
+
} catch (err) {
|
|
280
|
+
warn(`Failed to write file ${file.name}:`, err)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Deletes a local file from disk
|
|
287
|
+
*/
|
|
288
|
+
export async function deleteLocalFile(
|
|
289
|
+
fileName: string,
|
|
290
|
+
filesDir: string,
|
|
291
|
+
hashTracker: HashTracker
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const normalized = resolveRemoteReference(filesDir, fileName)
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// CRITICAL ORDER: Mark delete FIRST (in memory) to prevent echo
|
|
297
|
+
hashTracker.markDelete(normalized.relativePath)
|
|
298
|
+
|
|
299
|
+
// THEN delete from disk
|
|
300
|
+
await fs.unlink(normalized.absolutePath)
|
|
301
|
+
|
|
302
|
+
// Clear the hash immediately
|
|
303
|
+
hashTracker.forget(normalized.relativePath)
|
|
304
|
+
|
|
305
|
+
info(`Deleted file: ${normalized.relativePath}`)
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const nodeError = err as NodeJS.ErrnoException
|
|
308
|
+
|
|
309
|
+
if (nodeError?.code === "ENOENT") {
|
|
310
|
+
// Treat missing files as already deleted to keep hash tracker in sync
|
|
311
|
+
hashTracker.forget(normalized.relativePath)
|
|
312
|
+
info(`File already deleted: ${normalized.relativePath}`)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Clear pending delete marker immediately on failure
|
|
317
|
+
hashTracker.clearDelete(normalized.relativePath)
|
|
318
|
+
warn(`Failed to delete file ${fileName}:`, err)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Reads a single file from disk (safe, returns null on error)
|
|
324
|
+
*/
|
|
325
|
+
export async function readFileSafe(
|
|
326
|
+
fileName: string,
|
|
327
|
+
filesDir: string
|
|
328
|
+
): Promise<string | null> {
|
|
329
|
+
const normalized = resolveRemoteReference(filesDir, fileName)
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return await fs.readFile(normalized.absolutePath, "utf-8")
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readLocalSnapshot(absolutePath: string) {
|
|
339
|
+
try {
|
|
340
|
+
const [content, stats] = await Promise.all([
|
|
341
|
+
fs.readFile(absolutePath, "utf-8"),
|
|
342
|
+
fs.stat(absolutePath),
|
|
343
|
+
])
|
|
344
|
+
return { content, modifiedAt: stats.mtimeMs }
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolveRemoteReference(filesDir: string, rawName: string) {
|
|
351
|
+
const normalized = sanitizeRelativePath(rawName)
|
|
352
|
+
const absolutePath = path.join(filesDir, normalized.relativePath)
|
|
353
|
+
return { ...normalized, absolutePath }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function sanitizeRelativePath(relativePath: string) {
|
|
357
|
+
const trimmed = normalizePath(relativePath.trim())
|
|
358
|
+
const hasExtension = SUPPORTED_EXTENSIONS.some((ext) =>
|
|
359
|
+
trimmed.toLowerCase().endsWith(ext)
|
|
360
|
+
)
|
|
361
|
+
const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`
|
|
362
|
+
// Don't capitalize when processing remote files - preserve exact casing from Framer
|
|
363
|
+
const sanitized = sanitizeFilePath(candidate, false)
|
|
364
|
+
const normalized = normalizePath(sanitized.path)
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
relativePath: normalized,
|
|
368
|
+
extension:
|
|
369
|
+
sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isSupportedExtension(fileName: string) {
|
|
374
|
+
const lower = fileName.toLowerCase()
|
|
375
|
+
return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Removed ensureSanitizedFileOnDisk as we want to trust user file names
|