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
package/dist/index.js
ADDED
|
@@ -0,0 +1,2021 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import "url";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { setupTypeAcquisition } from "@typescript/ata";
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
|
|
12
|
+
//#region src/utils/logging.ts
|
|
13
|
+
/**
|
|
14
|
+
* Logging utilities for consistent output
|
|
15
|
+
*/
|
|
16
|
+
let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
|
|
17
|
+
LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
|
|
18
|
+
LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
|
|
19
|
+
LogLevel$1[LogLevel$1["WARN"] = 2] = "WARN";
|
|
20
|
+
LogLevel$1[LogLevel$1["ERROR"] = 3] = "ERROR";
|
|
21
|
+
return LogLevel$1;
|
|
22
|
+
}({});
|
|
23
|
+
let currentLevel = LogLevel.INFO;
|
|
24
|
+
function setLogLevel(level) {
|
|
25
|
+
currentLevel = level;
|
|
26
|
+
}
|
|
27
|
+
function debug(message, ...args) {
|
|
28
|
+
if (currentLevel <= LogLevel.DEBUG) console.debug(`[DEBUG] ${message}`, ...args);
|
|
29
|
+
}
|
|
30
|
+
function info(message, ...args) {
|
|
31
|
+
if (currentLevel <= LogLevel.INFO) console.info(`[INFO] ${message}`, ...args);
|
|
32
|
+
}
|
|
33
|
+
function warn(message, ...args) {
|
|
34
|
+
if (currentLevel <= LogLevel.WARN) console.warn(`[WARN] ${message}`, ...args);
|
|
35
|
+
}
|
|
36
|
+
function error(message, ...args) {
|
|
37
|
+
if (currentLevel <= LogLevel.ERROR) console.error(`[ERROR] ${message}`, ...args);
|
|
38
|
+
}
|
|
39
|
+
function success(message, ...args) {
|
|
40
|
+
if (currentLevel <= LogLevel.INFO) console.log(`✓ ${message}`, ...args);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/helpers/connection.ts
|
|
45
|
+
/**
|
|
46
|
+
* Initializes a WebSocket server and returns a connection interface
|
|
47
|
+
*/
|
|
48
|
+
function initConnection(port) {
|
|
49
|
+
const wss = new WebSocketServer({ port });
|
|
50
|
+
const handlers = {};
|
|
51
|
+
info(`WebSocket server listening on port ${port}`);
|
|
52
|
+
wss.on("connection", (ws) => {
|
|
53
|
+
info("Client connected");
|
|
54
|
+
ws.on("message", (data) => {
|
|
55
|
+
try {
|
|
56
|
+
const message = JSON.parse(data.toString());
|
|
57
|
+
if (message.type === "handshake") handlers.onHandshake?.(ws, message);
|
|
58
|
+
else handlers.onMessage?.(message);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
error("Failed to parse message:", err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
ws.on("close", () => {
|
|
64
|
+
info("Client disconnected");
|
|
65
|
+
handlers.onDisconnect?.();
|
|
66
|
+
});
|
|
67
|
+
ws.on("error", (err) => {
|
|
68
|
+
error("WebSocket error:", err);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
on(event, handler) {
|
|
73
|
+
if (event === "handshake") handlers.onHandshake = handler;
|
|
74
|
+
else if (event === "message") handlers.onMessage = handler;
|
|
75
|
+
else if (event === "disconnect") handlers.onDisconnect = handler;
|
|
76
|
+
},
|
|
77
|
+
close() {
|
|
78
|
+
wss.close();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Sends a message to a connected socket
|
|
84
|
+
*/
|
|
85
|
+
function sendMessage(socket, message) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
socket.send(JSON.stringify(message), (err) => {
|
|
88
|
+
if (err) reject(err);
|
|
89
|
+
else resolve();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region ../shared/dist/paths.js
|
|
96
|
+
/**
|
|
97
|
+
* File path normalization utilities
|
|
98
|
+
* Framer code files include extensions in their paths (.tsx, .ts, etc.)
|
|
99
|
+
*/
|
|
100
|
+
const firstCharacterRegex = /^[a-zA-Z$_]/;
|
|
101
|
+
const remainingCharactersRegex = /[^a-zA-Z0-9$_]/g;
|
|
102
|
+
const onlyDotsRegex = /^\.+$/;
|
|
103
|
+
const tsxExtension = ".tsx";
|
|
104
|
+
var NameType;
|
|
105
|
+
(function(NameType$1) {
|
|
106
|
+
NameType$1["Variable"] = "Variable";
|
|
107
|
+
NameType$1["Selector"] = "Selector";
|
|
108
|
+
NameType$1["Directory"] = "Directory";
|
|
109
|
+
})(NameType || (NameType = {}));
|
|
110
|
+
function sanitizedName(type, name) {
|
|
111
|
+
if (!name) return null;
|
|
112
|
+
let validName = name.trim();
|
|
113
|
+
if (validName.length === 0) return null;
|
|
114
|
+
const validFirstChar = type === NameType.Selector ? "_" : "$";
|
|
115
|
+
if (type === NameType.Directory) {
|
|
116
|
+
if (onlyDotsRegex.test(validName)) return null;
|
|
117
|
+
} else if (!firstCharacterRegex.test(validName)) validName = validFirstChar + validName;
|
|
118
|
+
validName = validName.replace(remainingCharactersRegex, "_");
|
|
119
|
+
validName = validName.replace(/_+/g, "_");
|
|
120
|
+
validName = validName.replace(/^\$_/u, validFirstChar);
|
|
121
|
+
return validName;
|
|
122
|
+
}
|
|
123
|
+
function sanitizedVariableName(name) {
|
|
124
|
+
return sanitizedName(NameType.Variable, name);
|
|
125
|
+
}
|
|
126
|
+
function sanitizedDirectoryName(name) {
|
|
127
|
+
return sanitizedName(NameType.Directory, name);
|
|
128
|
+
}
|
|
129
|
+
function capitalizeFirstLetter(str) {
|
|
130
|
+
if (str.length === 0) return str;
|
|
131
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
132
|
+
}
|
|
133
|
+
function hasValidExtension(fileName) {
|
|
134
|
+
if (fileName.endsWith(".json")) return true;
|
|
135
|
+
return /\.[tj]sx?$/u.test(fileName);
|
|
136
|
+
}
|
|
137
|
+
function splitExtension(fileName) {
|
|
138
|
+
const match = fileName.match(/^(.+?)(\.[^.]+)?$/);
|
|
139
|
+
if (!match) return [fileName, ""];
|
|
140
|
+
return [match[1], match[2]?.slice(1) || ""];
|
|
141
|
+
}
|
|
142
|
+
function dirname(filePath) {
|
|
143
|
+
const at = filePath.lastIndexOf("/");
|
|
144
|
+
if (at < 0) return "";
|
|
145
|
+
return filePath.slice(0, at);
|
|
146
|
+
}
|
|
147
|
+
function filename(filePath) {
|
|
148
|
+
const at = filePath.lastIndexOf("/") + 1;
|
|
149
|
+
return filePath.slice(at);
|
|
150
|
+
}
|
|
151
|
+
function pathJoin(...parts) {
|
|
152
|
+
let res = "";
|
|
153
|
+
parts.forEach((part) => {
|
|
154
|
+
while (part.startsWith("/")) part = part.slice(1);
|
|
155
|
+
while (part.endsWith("/")) part = part.slice(0, -1);
|
|
156
|
+
if (part === "") return;
|
|
157
|
+
if (res !== "") res += "/";
|
|
158
|
+
res += part;
|
|
159
|
+
});
|
|
160
|
+
return res;
|
|
161
|
+
}
|
|
162
|
+
function normalizePath(filePath) {
|
|
163
|
+
if (!filePath) return "";
|
|
164
|
+
const isAbsolute = filePath.startsWith("/");
|
|
165
|
+
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
166
|
+
const stack = [];
|
|
167
|
+
for (const segment of segments) {
|
|
168
|
+
if (!segment || segment === ".") continue;
|
|
169
|
+
if (segment === "..") {
|
|
170
|
+
if (stack.length > 0) stack.pop();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
stack.push(segment);
|
|
174
|
+
}
|
|
175
|
+
const normalized = stack.join("/");
|
|
176
|
+
if (isAbsolute) return `/${normalized}`;
|
|
177
|
+
return normalized;
|
|
178
|
+
}
|
|
179
|
+
function sanitizeFilePath(input, capitalizeReactComponent = true) {
|
|
180
|
+
const trimmed = input.trim();
|
|
181
|
+
let [inputName, extension] = splitExtension(filename(trimmed));
|
|
182
|
+
if (extension) extension = `.${extension}`;
|
|
183
|
+
const dirName = dirname(trimmed).split("/").map((part) => sanitizedDirectoryName(part)).filter((part) => Boolean(part)).join("/");
|
|
184
|
+
let name = sanitizedVariableName(inputName) ?? "MyComponent";
|
|
185
|
+
if ((!hasValidExtension(extension) || extension === tsxExtension) && capitalizeReactComponent) name = capitalizeFirstLetter(name);
|
|
186
|
+
const sanitizedPath = pathJoin(dirName, name + extension);
|
|
187
|
+
return {
|
|
188
|
+
path: sanitizedPath,
|
|
189
|
+
dirName,
|
|
190
|
+
name,
|
|
191
|
+
extension
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function isSupportedExtension$1(filePath) {
|
|
195
|
+
return /\.(tsx?|jsx?|json)$/i.test(filePath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/utils/paths.ts
|
|
200
|
+
/**
|
|
201
|
+
* Gets a relative path from the project directory
|
|
202
|
+
*/
|
|
203
|
+
function getRelativePath(projectDir, absolutePath) {
|
|
204
|
+
return path.relative(projectDir, absolutePath);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Normalizes a file path by:
|
|
208
|
+
* - Converting backslashes to forward slashes
|
|
209
|
+
* - Resolving . and .. segments
|
|
210
|
+
* - Removing duplicate slashes
|
|
211
|
+
*/
|
|
212
|
+
function normalizePath$1(filePath) {
|
|
213
|
+
if (!filePath) return "";
|
|
214
|
+
const isAbsolute = filePath.startsWith("/");
|
|
215
|
+
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
216
|
+
const stack = [];
|
|
217
|
+
for (const segment of segments) {
|
|
218
|
+
if (!segment || segment === ".") continue;
|
|
219
|
+
if (segment === "..") {
|
|
220
|
+
if (stack.length > 0) stack.pop();
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
stack.push(segment);
|
|
224
|
+
}
|
|
225
|
+
const normalized = stack.join("/");
|
|
226
|
+
if (isAbsolute) return `/${normalized}`;
|
|
227
|
+
return normalized;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/helpers/watcher.ts
|
|
232
|
+
/**
|
|
233
|
+
* Initializes a file watcher for the given directory
|
|
234
|
+
*/
|
|
235
|
+
function initWatcher(filesDir) {
|
|
236
|
+
const handlers = [];
|
|
237
|
+
const watcher = chokidar.watch(filesDir, {
|
|
238
|
+
ignored: /(^|[\/\\])\../,
|
|
239
|
+
persistent: true,
|
|
240
|
+
ignoreInitial: false
|
|
241
|
+
});
|
|
242
|
+
info(`Watching directory: ${filesDir}`);
|
|
243
|
+
const emitEvent = async (kind, absolutePath) => {
|
|
244
|
+
if (!isSupportedExtension$1(absolutePath)) return;
|
|
245
|
+
const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
|
|
246
|
+
const sanitized = sanitizeFilePath(rawRelativePath, false);
|
|
247
|
+
const relativePath = sanitized.path;
|
|
248
|
+
let effectiveAbsolutePath = absolutePath;
|
|
249
|
+
if (relativePath !== rawRelativePath && kind === "add") {
|
|
250
|
+
const newAbsolutePath = path.join(filesDir, relativePath);
|
|
251
|
+
try {
|
|
252
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
|
|
253
|
+
await fs.rename(absolutePath, newAbsolutePath);
|
|
254
|
+
info(`Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`);
|
|
255
|
+
effectiveAbsolutePath = newAbsolutePath;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
warn(`Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`, err);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let content;
|
|
261
|
+
if (kind !== "delete") try {
|
|
262
|
+
content = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
263
|
+
} catch (err) {
|
|
264
|
+
debug(`Failed to read file ${relativePath}:`, err);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const event = {
|
|
268
|
+
kind,
|
|
269
|
+
relativePath,
|
|
270
|
+
content
|
|
271
|
+
};
|
|
272
|
+
debug(`Watcher event: ${kind} ${relativePath}`);
|
|
273
|
+
for (const handler of handlers) handler(event);
|
|
274
|
+
};
|
|
275
|
+
watcher.on("add", (filePath) => emitEvent("add", filePath));
|
|
276
|
+
watcher.on("change", (filePath) => emitEvent("change", filePath));
|
|
277
|
+
watcher.on("unlink", (filePath) => emitEvent("delete", filePath));
|
|
278
|
+
return {
|
|
279
|
+
on(event, handler) {
|
|
280
|
+
if (event === "change") handlers.push(handler);
|
|
281
|
+
},
|
|
282
|
+
async close() {
|
|
283
|
+
await watcher.close();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/utils/state-persistence.ts
|
|
290
|
+
const STATE_FILE_NAME = ".framer-sync-state.json";
|
|
291
|
+
const CURRENT_VERSION = 2;
|
|
292
|
+
const SUPPORTED_EXTENSIONS$1 = [
|
|
293
|
+
".ts",
|
|
294
|
+
".tsx",
|
|
295
|
+
".js",
|
|
296
|
+
".jsx",
|
|
297
|
+
".json"
|
|
298
|
+
];
|
|
299
|
+
const DEFAULT_EXTENSION$1 = ".tsx";
|
|
300
|
+
function normalizePersistedFileName(fileName) {
|
|
301
|
+
let normalized = normalizePath$1(fileName.trim());
|
|
302
|
+
if (!SUPPORTED_EXTENSIONS$1.some((ext) => normalized.toLowerCase().endsWith(ext))) normalized = `${normalized}${DEFAULT_EXTENSION$1}`;
|
|
303
|
+
return normalized;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Hash file content to detect changes
|
|
307
|
+
*/
|
|
308
|
+
function hashFileContent(content) {
|
|
309
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Load persisted state from disk
|
|
313
|
+
*/
|
|
314
|
+
async function loadPersistedState(projectDir) {
|
|
315
|
+
const statePath = path.join(projectDir, STATE_FILE_NAME);
|
|
316
|
+
const result = /* @__PURE__ */ new Map();
|
|
317
|
+
try {
|
|
318
|
+
const data = await fs.readFile(statePath, "utf-8");
|
|
319
|
+
const parsed = JSON.parse(data);
|
|
320
|
+
if (parsed.version !== CURRENT_VERSION) {
|
|
321
|
+
warn(`State file version mismatch (expected ${CURRENT_VERSION}, got ${parsed.version}). Ignoring persisted state.`);
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
for (const [fileName, state] of Object.entries(parsed.files)) {
|
|
325
|
+
const normalizedName = normalizePersistedFileName(fileName);
|
|
326
|
+
if (normalizedName !== fileName) debug(`Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`);
|
|
327
|
+
result.set(normalizedName, state);
|
|
328
|
+
}
|
|
329
|
+
debug(`Loaded persisted state for ${result.size} files`);
|
|
330
|
+
return result;
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err.code === "ENOENT") {
|
|
333
|
+
debug("No persisted state found (first run)");
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
warn("Failed to load persisted state:", err);
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Save current state to disk
|
|
342
|
+
*/
|
|
343
|
+
async function savePersistedState(projectDir, state) {
|
|
344
|
+
const statePath = path.join(projectDir, STATE_FILE_NAME);
|
|
345
|
+
const persistedState = {
|
|
346
|
+
version: CURRENT_VERSION,
|
|
347
|
+
files: Object.fromEntries(state.entries())
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2));
|
|
351
|
+
debug(`Saved persisted state for ${state.size} files`);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
warn("Failed to save persisted state:", err);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/helpers/files.ts
|
|
359
|
+
const SUPPORTED_EXTENSIONS = [
|
|
360
|
+
".ts",
|
|
361
|
+
".tsx",
|
|
362
|
+
".js",
|
|
363
|
+
".jsx",
|
|
364
|
+
".json"
|
|
365
|
+
];
|
|
366
|
+
const DEFAULT_EXTENSION = ".tsx";
|
|
367
|
+
const DEFAULT_REMOTE_DRIFT_MS = 2e3;
|
|
368
|
+
/**
|
|
369
|
+
* Lists all supported files in the files directory
|
|
370
|
+
*/
|
|
371
|
+
async function listFiles(filesDir) {
|
|
372
|
+
const files = [];
|
|
373
|
+
async function walk(currentDir) {
|
|
374
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
const entryPath = path.join(currentDir, entry.name);
|
|
377
|
+
if (entry.isDirectory()) {
|
|
378
|
+
await walk(entryPath);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!isSupportedExtension(entry.name)) continue;
|
|
382
|
+
const relativePath = path.relative(filesDir, entryPath);
|
|
383
|
+
const normalizedPath = normalizePath(relativePath);
|
|
384
|
+
const sanitizedPath = sanitizeFilePath(normalizedPath, false).path;
|
|
385
|
+
try {
|
|
386
|
+
const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
|
|
387
|
+
files.push({
|
|
388
|
+
name: sanitizedPath,
|
|
389
|
+
content,
|
|
390
|
+
modifiedAt: stats.mtimeMs
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
warn(`Failed to read ${entryPath}:`, err);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
await walk(filesDir);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
warn("Failed to list files:", err);
|
|
401
|
+
}
|
|
402
|
+
return files;
|
|
403
|
+
}
|
|
404
|
+
async function detectConflicts(remoteFiles, filesDir, options = {}) {
|
|
405
|
+
const conflicts = [];
|
|
406
|
+
const writes = [];
|
|
407
|
+
const localOnly = [];
|
|
408
|
+
const detect = options.detectConflicts ?? true;
|
|
409
|
+
const preferRemote = options.preferRemote ?? false;
|
|
410
|
+
const persistedState = options.persistedState;
|
|
411
|
+
debug(`Detecting conflicts for ${remoteFiles.length} remote files`);
|
|
412
|
+
const localFiles = await listFiles(filesDir);
|
|
413
|
+
const localFileMap = new Map(localFiles.map((f) => [f.name, f]));
|
|
414
|
+
const processedFiles = /* @__PURE__ */ new Set();
|
|
415
|
+
for (const remote of remoteFiles) {
|
|
416
|
+
const normalized = resolveRemoteReference(filesDir, remote.name);
|
|
417
|
+
const local = localFileMap.get(normalized.relativePath);
|
|
418
|
+
processedFiles.add(normalized.relativePath);
|
|
419
|
+
const persisted = persistedState?.get(normalized.relativePath);
|
|
420
|
+
const localHash = local ? hashFileContent(local.content) : null;
|
|
421
|
+
const localMatchesPersisted = !!persisted && !!local && localHash === persisted.contentHash;
|
|
422
|
+
if (!local) {
|
|
423
|
+
writes.push({
|
|
424
|
+
name: normalized.relativePath,
|
|
425
|
+
content: remote.content,
|
|
426
|
+
modifiedAt: remote.modifiedAt
|
|
427
|
+
});
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (local.content === remote.content) continue;
|
|
431
|
+
if (!detect || preferRemote) {
|
|
432
|
+
writes.push({
|
|
433
|
+
name: normalized.relativePath,
|
|
434
|
+
content: remote.content,
|
|
435
|
+
modifiedAt: remote.modifiedAt
|
|
436
|
+
});
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const localClean = persisted ? localMatchesPersisted : void 0;
|
|
440
|
+
conflicts.push({
|
|
441
|
+
fileName: normalized.relativePath,
|
|
442
|
+
localContent: local.content,
|
|
443
|
+
remoteContent: remote.content,
|
|
444
|
+
localModifiedAt: local.modifiedAt,
|
|
445
|
+
remoteModifiedAt: remote.modifiedAt,
|
|
446
|
+
lastSyncedAt: persisted?.timestamp,
|
|
447
|
+
localClean
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
for (const local of localFiles) if (!processedFiles.has(local.name)) localOnly.push({
|
|
451
|
+
name: local.name,
|
|
452
|
+
content: local.content,
|
|
453
|
+
modifiedAt: local.modifiedAt
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
conflicts,
|
|
457
|
+
writes,
|
|
458
|
+
localOnly
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function autoResolveConflicts(conflicts, versions, options = {}) {
|
|
462
|
+
const versionMap = new Map(versions.map((version) => [version.fileName, version.latestRemoteVersionMs]));
|
|
463
|
+
const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS;
|
|
464
|
+
const autoResolvedLocal = [];
|
|
465
|
+
const autoResolvedRemote = [];
|
|
466
|
+
const remainingConflicts = [];
|
|
467
|
+
for (const conflict of conflicts) {
|
|
468
|
+
const latestRemoteVersionMs = versionMap.get(conflict.fileName);
|
|
469
|
+
const lastSyncedAt = conflict.lastSyncedAt;
|
|
470
|
+
info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`);
|
|
471
|
+
if (!latestRemoteVersionMs) {
|
|
472
|
+
info(`-> No remote version data, keeping conflict`);
|
|
473
|
+
remainingConflicts.push(conflict);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!lastSyncedAt) {
|
|
477
|
+
info(`-> No last sync timestamp, keeping conflict`);
|
|
478
|
+
remainingConflicts.push(conflict);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
info(`-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`);
|
|
482
|
+
info(`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`);
|
|
483
|
+
const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
|
|
484
|
+
const localClean = conflict.localClean === true;
|
|
485
|
+
if (remoteUnchanged && !localClean) {
|
|
486
|
+
info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`);
|
|
487
|
+
autoResolvedLocal.push(conflict);
|
|
488
|
+
} else if (localClean && !remoteUnchanged) {
|
|
489
|
+
info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`);
|
|
490
|
+
autoResolvedRemote.push(conflict);
|
|
491
|
+
} else if (remoteUnchanged && localClean) info(` -> Both unchanged. Skipping (no conflict).`);
|
|
492
|
+
else {
|
|
493
|
+
info(` -> Both sides changed. Real conflict.`);
|
|
494
|
+
remainingConflicts.push(conflict);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
autoResolvedLocal,
|
|
499
|
+
autoResolvedRemote,
|
|
500
|
+
remainingConflicts
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
505
|
+
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
506
|
+
*/
|
|
507
|
+
async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
|
|
508
|
+
info(`Writing ${files.length} remote files`);
|
|
509
|
+
for (const file of files) try {
|
|
510
|
+
const normalized = resolveRemoteReference(filesDir, file.name);
|
|
511
|
+
const fullPath = normalized.absolutePath;
|
|
512
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
513
|
+
hashTracker.remember(normalized.relativePath, file.content);
|
|
514
|
+
await fs.writeFile(fullPath, file.content, "utf-8");
|
|
515
|
+
debug(`Wrote file: ${normalized.relativePath}`);
|
|
516
|
+
installer?.process(normalized.relativePath, file.content);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
warn(`Failed to write file ${file.name}:`, err);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Deletes a local file from disk
|
|
523
|
+
*/
|
|
524
|
+
async function deleteLocalFile(fileName, filesDir, hashTracker) {
|
|
525
|
+
const normalized = resolveRemoteReference(filesDir, fileName);
|
|
526
|
+
try {
|
|
527
|
+
hashTracker.markDelete(normalized.relativePath);
|
|
528
|
+
await fs.unlink(normalized.absolutePath);
|
|
529
|
+
hashTracker.forget(normalized.relativePath);
|
|
530
|
+
info(`Deleted file: ${normalized.relativePath}`);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const nodeError = err;
|
|
533
|
+
if (nodeError?.code === "ENOENT") {
|
|
534
|
+
hashTracker.forget(normalized.relativePath);
|
|
535
|
+
info(`File already deleted: ${normalized.relativePath}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
hashTracker.clearDelete(normalized.relativePath);
|
|
539
|
+
warn(`Failed to delete file ${fileName}:`, err);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Reads a single file from disk (safe, returns null on error)
|
|
544
|
+
*/
|
|
545
|
+
async function readFileSafe(fileName, filesDir) {
|
|
546
|
+
const normalized = resolveRemoteReference(filesDir, fileName);
|
|
547
|
+
try {
|
|
548
|
+
return await fs.readFile(normalized.absolutePath, "utf-8");
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function resolveRemoteReference(filesDir, rawName) {
|
|
554
|
+
const normalized = sanitizeRelativePath(rawName);
|
|
555
|
+
const absolutePath = path.join(filesDir, normalized.relativePath);
|
|
556
|
+
return {
|
|
557
|
+
...normalized,
|
|
558
|
+
absolutePath
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function sanitizeRelativePath(relativePath) {
|
|
562
|
+
const trimmed = normalizePath(relativePath.trim());
|
|
563
|
+
const hasExtension = SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext));
|
|
564
|
+
const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`;
|
|
565
|
+
const sanitized = sanitizeFilePath(candidate, false);
|
|
566
|
+
const normalized = normalizePath(sanitized.path);
|
|
567
|
+
return {
|
|
568
|
+
relativePath: normalized,
|
|
569
|
+
extension: sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function isSupportedExtension(fileName) {
|
|
573
|
+
const lower = fileName.toLowerCase();
|
|
574
|
+
return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/utils/imports.ts
|
|
579
|
+
/**
|
|
580
|
+
* Extract npm and URL-based imports from source code.
|
|
581
|
+
*/
|
|
582
|
+
function extractImports(code) {
|
|
583
|
+
const imports = [];
|
|
584
|
+
const seen = /* @__PURE__ */ new Set();
|
|
585
|
+
const npmRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^.\/][^'"]+)['"]/g;
|
|
586
|
+
const urlRegex = /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g;
|
|
587
|
+
let match;
|
|
588
|
+
while ((match = npmRegex.exec(code)) !== null) {
|
|
589
|
+
const pkgName = match[1];
|
|
590
|
+
const normalized = pkgName.startsWith("@") ? pkgName.split("/").slice(0, 2).join("/") : pkgName.split("/")[0];
|
|
591
|
+
if (!seen.has(normalized)) {
|
|
592
|
+
seen.add(normalized);
|
|
593
|
+
imports.push({
|
|
594
|
+
type: "npm",
|
|
595
|
+
name: normalized,
|
|
596
|
+
raw: match[0]
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
while ((match = urlRegex.exec(code)) !== null) {
|
|
601
|
+
const pkgName = extractPackageFromUrl(match[0]);
|
|
602
|
+
if (pkgName && !seen.has(pkgName)) {
|
|
603
|
+
seen.add(pkgName);
|
|
604
|
+
imports.push({
|
|
605
|
+
type: "url",
|
|
606
|
+
name: pkgName,
|
|
607
|
+
raw: match[0]
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return imports;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Attempt to derive an npm-style package specifier from a URL import.
|
|
615
|
+
*/
|
|
616
|
+
function extractPackageFromUrl(url) {
|
|
617
|
+
const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/);
|
|
618
|
+
return match?.[1] ?? null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/helpers/installer.ts
|
|
623
|
+
const FETCH_TIMEOUT_MS = 6e4;
|
|
624
|
+
const MAX_FETCH_RETRIES = 3;
|
|
625
|
+
const REACT_TYPES_VERSION = "18.3.12";
|
|
626
|
+
const REACT_DOM_TYPES_VERSION = "18.3.1";
|
|
627
|
+
const CORE_LIBRARIES = ["framer-motion", "framer"];
|
|
628
|
+
const JSON_EXTENSION_REGEX = /\.json$/i;
|
|
629
|
+
/**
|
|
630
|
+
* Installer class for managing automatic type acquisition.
|
|
631
|
+
*/
|
|
632
|
+
var Installer = class {
|
|
633
|
+
projectDir;
|
|
634
|
+
ata;
|
|
635
|
+
processedImports = /* @__PURE__ */ new Set();
|
|
636
|
+
initializationPromise = null;
|
|
637
|
+
constructor(config) {
|
|
638
|
+
this.projectDir = config.projectDir;
|
|
639
|
+
const seenPackages = /* @__PURE__ */ new Set();
|
|
640
|
+
this.ata = setupTypeAcquisition({
|
|
641
|
+
projectName: "framer-code-link",
|
|
642
|
+
typescript: ts,
|
|
643
|
+
logger: console,
|
|
644
|
+
fetcher: fetchWithRetry,
|
|
645
|
+
delegate: {
|
|
646
|
+
started: () => {
|
|
647
|
+
seenPackages.clear();
|
|
648
|
+
debug("ATA: fetching type definitions...");
|
|
649
|
+
},
|
|
650
|
+
progress: () => {},
|
|
651
|
+
finished: (files) => {
|
|
652
|
+
if (files && files.size > 0) debug("ATA: type acquisition complete");
|
|
653
|
+
},
|
|
654
|
+
errorMessage: (message, error$1) => {
|
|
655
|
+
warn(`ATA warning: ${message}`, error$1);
|
|
656
|
+
},
|
|
657
|
+
receivedFile: async (code, receivedPath) => {
|
|
658
|
+
const normalized = receivedPath.replace(/^\//, "");
|
|
659
|
+
const destination = path.join(this.projectDir, normalized);
|
|
660
|
+
const pkgMatch = receivedPath.match(/\/node_modules\/(@?[^\/]+(?:\/[^\/]+)?)\//);
|
|
661
|
+
let isFromCache = false;
|
|
662
|
+
try {
|
|
663
|
+
const existing = await fs.readFile(destination, "utf-8");
|
|
664
|
+
if (existing === code) {
|
|
665
|
+
isFromCache = true;
|
|
666
|
+
if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
|
|
667
|
+
seenPackages.add(pkgMatch[1]);
|
|
668
|
+
debug(`📦 Types: ${pkgMatch[1]} (from disk cache)`);
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
} catch {}
|
|
673
|
+
if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
|
|
674
|
+
seenPackages.add(pkgMatch[1]);
|
|
675
|
+
info(`📦 Types: ${pkgMatch[1]}`);
|
|
676
|
+
}
|
|
677
|
+
await this.writeTypeFile(receivedPath, code);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
info("Type installer initialized");
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Ensure the project scaffolding exists (tsconfig, declarations, etc.)
|
|
685
|
+
*/
|
|
686
|
+
async initialize() {
|
|
687
|
+
if (this.initializationPromise) return this.initializationPromise;
|
|
688
|
+
this.initializationPromise = this.initializeProject().then(() => {
|
|
689
|
+
debug("Type installer initialization complete");
|
|
690
|
+
}).catch((err) => {
|
|
691
|
+
this.initializationPromise = null;
|
|
692
|
+
throw err;
|
|
693
|
+
});
|
|
694
|
+
return this.initializationPromise;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Fire-and-forget processing of a component file to fetch missing types.
|
|
698
|
+
* JSON files are ignored.
|
|
699
|
+
*/
|
|
700
|
+
process(fileName, content) {
|
|
701
|
+
if (!content || JSON_EXTENSION_REGEX.test(fileName)) return;
|
|
702
|
+
Promise.resolve().then(async () => {
|
|
703
|
+
await this.processImports(fileName, content);
|
|
704
|
+
}).catch((err) => {
|
|
705
|
+
debug(`Type installer failed for ${fileName}`, err);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
async initializeProject() {
|
|
709
|
+
await Promise.all([
|
|
710
|
+
this.ensureTsConfig(),
|
|
711
|
+
this.ensurePrettierConfig(),
|
|
712
|
+
this.ensureFramerDeclarations(),
|
|
713
|
+
this.ensurePackageJson()
|
|
714
|
+
]);
|
|
715
|
+
Promise.resolve().then(async () => {
|
|
716
|
+
await this.ensureReact18Types();
|
|
717
|
+
const coreImports = CORE_LIBRARIES.map((lib) => `import "${lib}";`).join("\n");
|
|
718
|
+
await this.ata(coreImports);
|
|
719
|
+
}).catch((err) => {
|
|
720
|
+
debug("Type installation failed", err);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
async processImports(fileName, content) {
|
|
724
|
+
const imports = extractImports(content).filter((imp) => imp.type === "npm");
|
|
725
|
+
if (imports.length === 0) return;
|
|
726
|
+
const hash = imports.map((imp) => imp.name).sort().join(",");
|
|
727
|
+
if (this.processedImports.has(hash)) return;
|
|
728
|
+
this.processedImports.add(hash);
|
|
729
|
+
info(`Processing imports for ${fileName} (${imports.length} packages)`);
|
|
730
|
+
try {
|
|
731
|
+
await this.ata(content);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
warn(`ATA failed for ${fileName}`, err);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async writeTypeFile(receivedPath, code) {
|
|
737
|
+
const normalized = receivedPath.replace(/^\//, "");
|
|
738
|
+
const destination = path.join(this.projectDir, normalized);
|
|
739
|
+
try {
|
|
740
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
741
|
+
await fs.writeFile(destination, code, "utf-8");
|
|
742
|
+
} catch (err) {
|
|
743
|
+
warn(`Failed to write type file ${destination}`, err);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (normalized.match(/node_modules\/@types\/[^\/]+\/index\.d\.ts$/)) await this.ensureTypesPackageJson(normalized);
|
|
747
|
+
if (normalized.includes("node_modules/@types/react/index.d.ts")) await this.patchReactTypes(destination);
|
|
748
|
+
}
|
|
749
|
+
async ensureTypesPackageJson(normalizedPath) {
|
|
750
|
+
const pkgMatch = normalizedPath.match(/node_modules\/(@types\/[^\/]+)\//);
|
|
751
|
+
if (!pkgMatch) return;
|
|
752
|
+
const pkgName = pkgMatch[1];
|
|
753
|
+
const pkgDir = path.join(this.projectDir, "node_modules", pkgName);
|
|
754
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
755
|
+
try {
|
|
756
|
+
const response = await fetch(`https://registry.npmjs.org/${pkgName}`);
|
|
757
|
+
if (!response.ok) return;
|
|
758
|
+
const npmData = await response.json();
|
|
759
|
+
const version = npmData["dist-tags"]?.latest;
|
|
760
|
+
if (!version || !npmData.versions?.[version]) return;
|
|
761
|
+
const pkg = npmData.versions[version];
|
|
762
|
+
if (pkg.exports && typeof pkg.exports === "object") {
|
|
763
|
+
const fixExport = (value) => {
|
|
764
|
+
if (typeof value === "string") {
|
|
765
|
+
const tsPath = value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
|
|
766
|
+
return { types: tsPath };
|
|
767
|
+
}
|
|
768
|
+
if (value && typeof value === "object") {
|
|
769
|
+
if ((value.import || value.require) && !value.types) {
|
|
770
|
+
const base = value.import || value.require;
|
|
771
|
+
value.types = base.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return value;
|
|
775
|
+
};
|
|
776
|
+
for (const key of Object.keys(pkg.exports)) pkg.exports[key] = fixExport(pkg.exports[key]);
|
|
777
|
+
}
|
|
778
|
+
await fs.mkdir(pkgDir, { recursive: true });
|
|
779
|
+
await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2));
|
|
780
|
+
} catch {}
|
|
781
|
+
}
|
|
782
|
+
async patchReactTypes(destination) {
|
|
783
|
+
try {
|
|
784
|
+
let content = await fs.readFile(destination, "utf-8");
|
|
785
|
+
if (content.includes("function useRef<T = undefined>()")) return;
|
|
786
|
+
const overloadPattern = /function useRef<T>\(initialValue: T \| undefined\): RefObject<T \| undefined>;/;
|
|
787
|
+
if (!overloadPattern.test(content)) return;
|
|
788
|
+
content = content.replace(overloadPattern, `function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
|
|
789
|
+
function useRef<T = undefined>(): MutableRefObject<T | undefined>;`);
|
|
790
|
+
await fs.writeFile(destination, content, "utf-8");
|
|
791
|
+
} catch {}
|
|
792
|
+
}
|
|
793
|
+
async ensureTsConfig() {
|
|
794
|
+
const tsconfigPath = path.join(this.projectDir, "tsconfig.json");
|
|
795
|
+
try {
|
|
796
|
+
await fs.access(tsconfigPath);
|
|
797
|
+
debug("tsconfig.json already exists");
|
|
798
|
+
} catch {
|
|
799
|
+
const config = {
|
|
800
|
+
compilerOptions: {
|
|
801
|
+
noEmit: true,
|
|
802
|
+
target: "ES2021",
|
|
803
|
+
lib: [
|
|
804
|
+
"ES2021",
|
|
805
|
+
"DOM",
|
|
806
|
+
"DOM.Iterable"
|
|
807
|
+
],
|
|
808
|
+
module: "ESNext",
|
|
809
|
+
moduleResolution: "bundler",
|
|
810
|
+
customConditions: ["source"],
|
|
811
|
+
jsx: "react-jsx",
|
|
812
|
+
allowJs: true,
|
|
813
|
+
allowSyntheticDefaultImports: true,
|
|
814
|
+
strict: false,
|
|
815
|
+
allowImportingTsExtensions: true,
|
|
816
|
+
resolveJsonModule: true,
|
|
817
|
+
esModuleInterop: true,
|
|
818
|
+
skipLibCheck: true,
|
|
819
|
+
typeRoots: ["./node_modules/@types"]
|
|
820
|
+
},
|
|
821
|
+
include: ["files/**/*", "framer-modules.d.ts"]
|
|
822
|
+
};
|
|
823
|
+
await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
|
|
824
|
+
info("Created tsconfig.json");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async ensurePrettierConfig() {
|
|
828
|
+
const prettierPath = path.join(this.projectDir, ".prettierrc");
|
|
829
|
+
try {
|
|
830
|
+
await fs.access(prettierPath);
|
|
831
|
+
debug(".prettierrc already exists");
|
|
832
|
+
} catch {
|
|
833
|
+
const config = {
|
|
834
|
+
tabWidth: 4,
|
|
835
|
+
semi: false,
|
|
836
|
+
trailingComma: "es5"
|
|
837
|
+
};
|
|
838
|
+
await fs.writeFile(prettierPath, JSON.stringify(config, null, 2));
|
|
839
|
+
info("Created .prettierrc");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async ensureFramerDeclarations() {
|
|
843
|
+
const declarationsPath = path.join(this.projectDir, "framer-modules.d.ts");
|
|
844
|
+
try {
|
|
845
|
+
await fs.access(declarationsPath);
|
|
846
|
+
debug("framer-modules.d.ts already exists");
|
|
847
|
+
} catch {
|
|
848
|
+
const declarations = `// Type declarations for Framer URL imports
|
|
849
|
+
declare module "https://framer.com/m/*"
|
|
850
|
+
|
|
851
|
+
declare module "https://framerusercontent.com/*"
|
|
852
|
+
|
|
853
|
+
declare module "*.json"
|
|
854
|
+
`;
|
|
855
|
+
await fs.writeFile(declarationsPath, declarations);
|
|
856
|
+
info("Created framer-modules.d.ts");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
async ensurePackageJson() {
|
|
860
|
+
const packagePath = path.join(this.projectDir, "package.json");
|
|
861
|
+
try {
|
|
862
|
+
await fs.access(packagePath);
|
|
863
|
+
debug("package.json already exists");
|
|
864
|
+
} catch {
|
|
865
|
+
const pkg = {
|
|
866
|
+
name: path.basename(this.projectDir),
|
|
867
|
+
version: "1.0.0",
|
|
868
|
+
private: true,
|
|
869
|
+
description: "Framer files synced with framer-code-link"
|
|
870
|
+
};
|
|
871
|
+
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2));
|
|
872
|
+
info("Created package.json");
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async ensureReact18Types() {
|
|
876
|
+
const reactTypesDir = path.join(this.projectDir, "node_modules/@types/react");
|
|
877
|
+
const reactFiles = [
|
|
878
|
+
"package.json",
|
|
879
|
+
"index.d.ts",
|
|
880
|
+
"global.d.ts",
|
|
881
|
+
"jsx-runtime.d.ts",
|
|
882
|
+
"jsx-dev-runtime.d.ts"
|
|
883
|
+
];
|
|
884
|
+
if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) info("📦 React types (from cache)");
|
|
885
|
+
else {
|
|
886
|
+
info("Downloading React 18 types for Framer compatibility...");
|
|
887
|
+
await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
|
|
888
|
+
}
|
|
889
|
+
const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
|
|
890
|
+
const reactDomFiles = [
|
|
891
|
+
"package.json",
|
|
892
|
+
"index.d.ts",
|
|
893
|
+
"client.d.ts"
|
|
894
|
+
];
|
|
895
|
+
if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) info("📦 React DOM types (from cache)");
|
|
896
|
+
else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
|
|
897
|
+
}
|
|
898
|
+
async hasTypePackage(destinationDir, version, files) {
|
|
899
|
+
try {
|
|
900
|
+
const pkgJsonPath = path.join(destinationDir, "package.json");
|
|
901
|
+
const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
|
|
902
|
+
const parsed = JSON.parse(pkgJson);
|
|
903
|
+
if (parsed.version !== version) return false;
|
|
904
|
+
for (const file of files) {
|
|
905
|
+
if (file === "package.json") continue;
|
|
906
|
+
await fs.access(path.join(destinationDir, file));
|
|
907
|
+
}
|
|
908
|
+
return true;
|
|
909
|
+
} catch {
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async downloadTypePackage(pkgName, version, destinationDir, files) {
|
|
914
|
+
const baseUrl = `https://unpkg.com/${pkgName}@${version}`;
|
|
915
|
+
await fs.mkdir(destinationDir, { recursive: true });
|
|
916
|
+
await Promise.all(files.map(async (file) => {
|
|
917
|
+
const destination = path.join(destinationDir, file);
|
|
918
|
+
try {
|
|
919
|
+
await fs.access(destination);
|
|
920
|
+
return;
|
|
921
|
+
} catch {}
|
|
922
|
+
try {
|
|
923
|
+
const response = await fetch(`${baseUrl}/${file}`);
|
|
924
|
+
if (!response.ok) return;
|
|
925
|
+
const content = await response.text();
|
|
926
|
+
await fs.writeFile(destination, content);
|
|
927
|
+
} catch {}
|
|
928
|
+
}));
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
932
|
+
const urlString = typeof url === "string" ? url : url.toString();
|
|
933
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
934
|
+
const controller = new AbortController();
|
|
935
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
936
|
+
try {
|
|
937
|
+
const response = await fetch(url, {
|
|
938
|
+
...init,
|
|
939
|
+
signal: controller.signal
|
|
940
|
+
});
|
|
941
|
+
clearTimeout(timeout);
|
|
942
|
+
return response;
|
|
943
|
+
} catch (error$1) {
|
|
944
|
+
clearTimeout(timeout);
|
|
945
|
+
const isRetryable = error$1?.cause?.code === "ECONNRESET" || error$1?.cause?.code === "ETIMEDOUT" || error$1?.cause?.code === "UND_ERR_CONNECT_TIMEOUT" || error$1?.message?.includes("timeout");
|
|
946
|
+
if (attempt < retries && isRetryable) {
|
|
947
|
+
const delay = attempt * 1e3;
|
|
948
|
+
warn(`Fetch failed (${error$1?.cause?.code || error$1?.message}) for ${urlString}, retrying in ${delay}ms...`);
|
|
949
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
warn(`Fetch failed for ${urlString}`, error$1);
|
|
953
|
+
throw error$1;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`Max retries exceeded for ${urlString}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/utils/hashing.ts
|
|
961
|
+
/**
|
|
962
|
+
* Creates a hash tracker instance for echo prevention
|
|
963
|
+
*/
|
|
964
|
+
function createHashTracker() {
|
|
965
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
966
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
967
|
+
return {
|
|
968
|
+
remember(filePath, content) {
|
|
969
|
+
const hash = hashContent(content);
|
|
970
|
+
hashes.set(filePath, hash);
|
|
971
|
+
},
|
|
972
|
+
shouldSkip(filePath, content) {
|
|
973
|
+
const currentHash = hashContent(content);
|
|
974
|
+
const storedHash = hashes.get(filePath);
|
|
975
|
+
return storedHash === currentHash;
|
|
976
|
+
},
|
|
977
|
+
forget(filePath) {
|
|
978
|
+
hashes.delete(filePath);
|
|
979
|
+
},
|
|
980
|
+
clear() {
|
|
981
|
+
hashes.clear();
|
|
982
|
+
},
|
|
983
|
+
markDelete(filePath) {
|
|
984
|
+
const existingTimer = pendingDeletes.get(filePath);
|
|
985
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
986
|
+
const timeout = setTimeout(() => {
|
|
987
|
+
pendingDeletes.delete(filePath);
|
|
988
|
+
}, 5e3);
|
|
989
|
+
pendingDeletes.set(filePath, timeout);
|
|
990
|
+
},
|
|
991
|
+
shouldSkipDelete(filePath) {
|
|
992
|
+
return pendingDeletes.has(filePath);
|
|
993
|
+
},
|
|
994
|
+
clearDelete(filePath) {
|
|
995
|
+
const timeout = pendingDeletes.get(filePath);
|
|
996
|
+
if (timeout) clearTimeout(timeout);
|
|
997
|
+
pendingDeletes.delete(filePath);
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Computes a hash of file content for comparison
|
|
1003
|
+
*/
|
|
1004
|
+
function hashContent(content) {
|
|
1005
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Generate a deterministic port number from a project hash
|
|
1009
|
+
* Port range: 3847-4096 (250 possible ports)
|
|
1010
|
+
* Uses simple hash to match client-side implementation
|
|
1011
|
+
*/
|
|
1012
|
+
function getPortFromHash(projectHash) {
|
|
1013
|
+
let hash = 0;
|
|
1014
|
+
for (let i = 0; i < projectHash.length; i++) {
|
|
1015
|
+
const char = projectHash.charCodeAt(i);
|
|
1016
|
+
hash = (hash << 5) - hash + char;
|
|
1017
|
+
hash = hash & hash;
|
|
1018
|
+
}
|
|
1019
|
+
const portOffset = Math.abs(hash) % 250;
|
|
1020
|
+
return 3847 + portOffset;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/utils/file-metadata-cache.ts
|
|
1025
|
+
var FileMetadataCache = class {
|
|
1026
|
+
metadata = /* @__PURE__ */ new Map();
|
|
1027
|
+
persisted = /* @__PURE__ */ new Map();
|
|
1028
|
+
projectDir = null;
|
|
1029
|
+
initialized = false;
|
|
1030
|
+
pendingPersist = null;
|
|
1031
|
+
async initialize(projectDir) {
|
|
1032
|
+
if (this.initialized && this.projectDir === projectDir) return;
|
|
1033
|
+
this.projectDir = projectDir;
|
|
1034
|
+
const loaded = await loadPersistedState(projectDir);
|
|
1035
|
+
this.persisted = loaded;
|
|
1036
|
+
this.metadata = /* @__PURE__ */ new Map();
|
|
1037
|
+
for (const [fileName, state] of loaded.entries()) this.metadata.set(fileName, {
|
|
1038
|
+
localHash: state.contentHash,
|
|
1039
|
+
lastSyncedHash: state.contentHash,
|
|
1040
|
+
lastRemoteTimestamp: state.timestamp
|
|
1041
|
+
});
|
|
1042
|
+
this.initialized = true;
|
|
1043
|
+
}
|
|
1044
|
+
get(fileName) {
|
|
1045
|
+
return this.metadata.get(fileName);
|
|
1046
|
+
}
|
|
1047
|
+
has(fileName) {
|
|
1048
|
+
return this.metadata.has(fileName);
|
|
1049
|
+
}
|
|
1050
|
+
size() {
|
|
1051
|
+
return this.metadata.size;
|
|
1052
|
+
}
|
|
1053
|
+
getPersistedState() {
|
|
1054
|
+
return this.persisted;
|
|
1055
|
+
}
|
|
1056
|
+
recordRemoteWrite(fileName, content, remoteModifiedAt) {
|
|
1057
|
+
const contentHash = hashFileContent(content);
|
|
1058
|
+
this.metadata.set(fileName, {
|
|
1059
|
+
localHash: contentHash,
|
|
1060
|
+
lastSyncedHash: contentHash,
|
|
1061
|
+
lastRemoteTimestamp: remoteModifiedAt
|
|
1062
|
+
});
|
|
1063
|
+
this.persisted.set(fileName, {
|
|
1064
|
+
contentHash,
|
|
1065
|
+
timestamp: remoteModifiedAt
|
|
1066
|
+
});
|
|
1067
|
+
this.schedulePersist();
|
|
1068
|
+
}
|
|
1069
|
+
recordSyncedSnapshot(fileName, contentHash, remoteModifiedAt) {
|
|
1070
|
+
this.metadata.set(fileName, {
|
|
1071
|
+
localHash: contentHash,
|
|
1072
|
+
lastSyncedHash: contentHash,
|
|
1073
|
+
lastRemoteTimestamp: remoteModifiedAt
|
|
1074
|
+
});
|
|
1075
|
+
this.persisted.set(fileName, {
|
|
1076
|
+
contentHash,
|
|
1077
|
+
timestamp: remoteModifiedAt
|
|
1078
|
+
});
|
|
1079
|
+
this.schedulePersist();
|
|
1080
|
+
}
|
|
1081
|
+
recordDelete(fileName) {
|
|
1082
|
+
this.metadata.delete(fileName);
|
|
1083
|
+
this.persisted.delete(fileName);
|
|
1084
|
+
this.schedulePersist();
|
|
1085
|
+
}
|
|
1086
|
+
async flush() {
|
|
1087
|
+
if (this.pendingPersist) await this.pendingPersist;
|
|
1088
|
+
}
|
|
1089
|
+
schedulePersist() {
|
|
1090
|
+
if (!this.projectDir) return;
|
|
1091
|
+
if (!this.pendingPersist) this.pendingPersist = (async () => {
|
|
1092
|
+
try {
|
|
1093
|
+
await Promise.resolve();
|
|
1094
|
+
await savePersistedState(this.projectDir, this.persisted);
|
|
1095
|
+
} finally {
|
|
1096
|
+
this.pendingPersist = null;
|
|
1097
|
+
}
|
|
1098
|
+
})();
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region src/helpers/user-actions.ts
|
|
1104
|
+
var PluginDisconnectedError = class extends Error {
|
|
1105
|
+
constructor() {
|
|
1106
|
+
super("Plugin disconnected");
|
|
1107
|
+
this.name = "PluginDisconnectedError";
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
var UserActionCoordinator = class {
|
|
1111
|
+
pendingActions = /* @__PURE__ */ new Map();
|
|
1112
|
+
/**
|
|
1113
|
+
* Sends the delete request to the plugin and awaits the user's decision
|
|
1114
|
+
*/
|
|
1115
|
+
async requestDeleteDecision(socket, { fileName, requireConfirmation }) {
|
|
1116
|
+
if (!socket) throw new Error("Cannot request delete decision: plugin not connected");
|
|
1117
|
+
if (requireConfirmation) {
|
|
1118
|
+
const confirmationPromise = this.awaitConfirmation(`delete:${fileName}`, "delete confirmation");
|
|
1119
|
+
await sendMessage(socket, {
|
|
1120
|
+
type: "file-delete",
|
|
1121
|
+
fileNames: [fileName],
|
|
1122
|
+
requireConfirmation: true
|
|
1123
|
+
});
|
|
1124
|
+
try {
|
|
1125
|
+
return await confirmationPromise;
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
if (err instanceof PluginDisconnectedError) {
|
|
1128
|
+
info(`[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`);
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
throw err;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
await sendMessage(socket, {
|
|
1135
|
+
type: "file-delete",
|
|
1136
|
+
fileNames: [fileName],
|
|
1137
|
+
requireConfirmation: false
|
|
1138
|
+
});
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Sends conflicts to the plugin and awaits user resolutions
|
|
1143
|
+
*/
|
|
1144
|
+
async requestConflictDecisions(socket, conflicts) {
|
|
1145
|
+
if (!socket) throw new Error("Cannot request conflict decision: plugin not connected");
|
|
1146
|
+
if (conflicts.length === 0) return /* @__PURE__ */ new Map();
|
|
1147
|
+
const pending = conflicts.map((conflict) => ({
|
|
1148
|
+
fileName: conflict.fileName,
|
|
1149
|
+
promise: this.awaitConfirmation(`conflict:${conflict.fileName}`, "conflict resolution")
|
|
1150
|
+
}));
|
|
1151
|
+
await sendMessage(socket, {
|
|
1152
|
+
type: "conflicts-detected",
|
|
1153
|
+
conflicts
|
|
1154
|
+
});
|
|
1155
|
+
try {
|
|
1156
|
+
const results = await Promise.all(pending.map(async ({ fileName, promise }) => [fileName, await promise]));
|
|
1157
|
+
return new Map(results);
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
if (err instanceof PluginDisconnectedError) {
|
|
1160
|
+
info("[USER-ACTION] Plugin disconnected while awaiting conflict decisions");
|
|
1161
|
+
return /* @__PURE__ */ new Map();
|
|
1162
|
+
}
|
|
1163
|
+
throw err;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Generic confirmation awaiter
|
|
1168
|
+
*/
|
|
1169
|
+
awaitConfirmation(actionId, description) {
|
|
1170
|
+
return new Promise((resolve, reject) => {
|
|
1171
|
+
this.pendingActions.set(actionId, {
|
|
1172
|
+
resolve,
|
|
1173
|
+
reject
|
|
1174
|
+
});
|
|
1175
|
+
info(`[USER-ACTION] Awaiting ${description}: ${actionId}`);
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Handle incoming confirmation response
|
|
1180
|
+
*/
|
|
1181
|
+
handleConfirmation(actionId, value) {
|
|
1182
|
+
const pending = this.pendingActions.get(actionId);
|
|
1183
|
+
if (!pending) {
|
|
1184
|
+
warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`);
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
this.pendingActions.delete(actionId);
|
|
1188
|
+
pending.resolve(value);
|
|
1189
|
+
info(`[USER-ACTION] Confirmed: ${actionId}`);
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Cleanup all pending actions (e.g., on disconnect)
|
|
1194
|
+
*/
|
|
1195
|
+
cleanup() {
|
|
1196
|
+
for (const [actionId, pending] of this.pendingActions.entries()) {
|
|
1197
|
+
pending.reject(new PluginDisconnectedError());
|
|
1198
|
+
warn(`[USER-ACTION] Cancelled pending action: ${actionId}`);
|
|
1199
|
+
}
|
|
1200
|
+
this.pendingActions.clear();
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/helpers/sync-validator.ts
|
|
1206
|
+
/**
|
|
1207
|
+
* Validates whether an incoming REMOTE file change should be applied
|
|
1208
|
+
*
|
|
1209
|
+
* During watching mode, we trust remote changes and apply them immediately.
|
|
1210
|
+
* During snapshot_processing, we queue them for later (to avoid race conditions).
|
|
1211
|
+
*
|
|
1212
|
+
* Note: This is for INCOMING changes from remote. Local changes (from watcher)
|
|
1213
|
+
* are handled separately and always sent during watching mode.
|
|
1214
|
+
*/
|
|
1215
|
+
function validateIncomingChange(file, fileMeta, currentMode) {
|
|
1216
|
+
if (currentMode === "snapshot_processing" || currentMode === "handshaking") return {
|
|
1217
|
+
action: "queue",
|
|
1218
|
+
reason: "snapshot-in-progress"
|
|
1219
|
+
};
|
|
1220
|
+
if (currentMode === "watching") {
|
|
1221
|
+
if (!fileMeta) return {
|
|
1222
|
+
action: "apply",
|
|
1223
|
+
reason: "new-file"
|
|
1224
|
+
};
|
|
1225
|
+
return {
|
|
1226
|
+
action: "apply",
|
|
1227
|
+
reason: "safe-update"
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
if (currentMode === "conflict_resolution") return {
|
|
1231
|
+
action: "queue",
|
|
1232
|
+
reason: "snapshot-in-progress"
|
|
1233
|
+
};
|
|
1234
|
+
return {
|
|
1235
|
+
action: "reject",
|
|
1236
|
+
reason: "unknown-file"
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
//#endregion
|
|
1241
|
+
//#region src/utils/project.ts
|
|
1242
|
+
function toPackageName(name) {
|
|
1243
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1244
|
+
}
|
|
1245
|
+
async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
|
|
1246
|
+
if (explicitDir) {
|
|
1247
|
+
const resolved = path.resolve(explicitDir);
|
|
1248
|
+
await fs.mkdir(path.join(resolved, "files"), { recursive: true });
|
|
1249
|
+
return resolved;
|
|
1250
|
+
}
|
|
1251
|
+
const cwd = process.cwd();
|
|
1252
|
+
const existing = await findExistingProjectDir(cwd, projectHash);
|
|
1253
|
+
if (existing) return existing;
|
|
1254
|
+
if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
|
|
1255
|
+
const dirName = toPackageName(projectName);
|
|
1256
|
+
const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
|
|
1257
|
+
await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
|
|
1258
|
+
const pkg = {
|
|
1259
|
+
name: dirName || projectHash,
|
|
1260
|
+
version: "1.0.0",
|
|
1261
|
+
private: true,
|
|
1262
|
+
framerProjectId: projectHash,
|
|
1263
|
+
framerProjectName: projectName
|
|
1264
|
+
};
|
|
1265
|
+
await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
|
|
1266
|
+
return projectDir;
|
|
1267
|
+
}
|
|
1268
|
+
async function findExistingProjectDir(baseDir, projectHash) {
|
|
1269
|
+
const candidate = path.join(baseDir, "package.json");
|
|
1270
|
+
if (await matchesProject(candidate, projectHash)) return baseDir;
|
|
1271
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
1272
|
+
for (const entry of entries) {
|
|
1273
|
+
if (!entry.isDirectory()) continue;
|
|
1274
|
+
const dir = path.join(baseDir, entry.name);
|
|
1275
|
+
if (await matchesProject(path.join(dir, "package.json"), projectHash)) return dir;
|
|
1276
|
+
}
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
async function matchesProject(packageJsonPath, projectHash) {
|
|
1280
|
+
try {
|
|
1281
|
+
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
1282
|
+
const pkg = JSON.parse(content);
|
|
1283
|
+
return pkg.framerProjectId === projectHash;
|
|
1284
|
+
} catch {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/controller.ts
|
|
1291
|
+
/** One-liner log effect builder */
|
|
1292
|
+
function log(level, message) {
|
|
1293
|
+
return {
|
|
1294
|
+
type: "LOG",
|
|
1295
|
+
level,
|
|
1296
|
+
message
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Pure state transition function
|
|
1301
|
+
* Takes current state + event, returns new state + effects to execute
|
|
1302
|
+
*/
|
|
1303
|
+
function transition(state, event) {
|
|
1304
|
+
const effects = [];
|
|
1305
|
+
switch (event.type) {
|
|
1306
|
+
case "HANDSHAKE": {
|
|
1307
|
+
if (state.mode !== "disconnected") {
|
|
1308
|
+
effects.push(log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`));
|
|
1309
|
+
return {
|
|
1310
|
+
state,
|
|
1311
|
+
effects
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
effects.push({
|
|
1315
|
+
type: "INIT_WORKSPACE",
|
|
1316
|
+
projectInfo: event.projectInfo
|
|
1317
|
+
}, { type: "LOAD_PERSISTED_STATE" }, {
|
|
1318
|
+
type: "SEND_MESSAGE",
|
|
1319
|
+
payload: { type: "request-files" }
|
|
1320
|
+
});
|
|
1321
|
+
return {
|
|
1322
|
+
state: {
|
|
1323
|
+
...state,
|
|
1324
|
+
mode: "handshaking",
|
|
1325
|
+
socket: event.socket
|
|
1326
|
+
},
|
|
1327
|
+
effects
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
case "FILE_SYNCED": {
|
|
1331
|
+
effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
|
|
1332
|
+
type: "UPDATE_FILE_METADATA",
|
|
1333
|
+
fileName: event.fileName,
|
|
1334
|
+
remoteModifiedAt: event.remoteModifiedAt
|
|
1335
|
+
});
|
|
1336
|
+
return {
|
|
1337
|
+
state,
|
|
1338
|
+
effects
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
case "DISCONNECT": {
|
|
1342
|
+
effects.push({ type: "PERSIST_STATE" }, log("info", "Disconnected, persisting state"));
|
|
1343
|
+
if (state.mode === "conflict_resolution") {
|
|
1344
|
+
const { pendingConflicts: _discarded,...rest } = state;
|
|
1345
|
+
return {
|
|
1346
|
+
state: {
|
|
1347
|
+
...rest,
|
|
1348
|
+
mode: "disconnected",
|
|
1349
|
+
socket: null
|
|
1350
|
+
},
|
|
1351
|
+
effects
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
return {
|
|
1355
|
+
state: {
|
|
1356
|
+
...state,
|
|
1357
|
+
mode: "disconnected",
|
|
1358
|
+
socket: null
|
|
1359
|
+
},
|
|
1360
|
+
effects
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
case "REQUEST_FILES": {
|
|
1364
|
+
if (state.mode === "disconnected") {
|
|
1365
|
+
effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
|
|
1366
|
+
return {
|
|
1367
|
+
state,
|
|
1368
|
+
effects
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
effects.push(log("info", "Plugin requested file list"), { type: "LIST_LOCAL_FILES" });
|
|
1372
|
+
return {
|
|
1373
|
+
state,
|
|
1374
|
+
effects
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
case "FILE_LIST": {
|
|
1378
|
+
if (state.mode !== "handshaking") {
|
|
1379
|
+
effects.push(log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`));
|
|
1380
|
+
return {
|
|
1381
|
+
state,
|
|
1382
|
+
effects
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
effects.push(log("info", `Received file list: ${event.files.length} files`));
|
|
1386
|
+
effects.push({
|
|
1387
|
+
type: "DETECT_CONFLICTS",
|
|
1388
|
+
remoteFiles: event.files
|
|
1389
|
+
});
|
|
1390
|
+
return {
|
|
1391
|
+
state: {
|
|
1392
|
+
...state,
|
|
1393
|
+
mode: "snapshot_processing",
|
|
1394
|
+
queuedDiffs: event.files
|
|
1395
|
+
},
|
|
1396
|
+
effects
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
case "CONFLICTS_DETECTED": {
|
|
1400
|
+
if (state.mode !== "snapshot_processing") {
|
|
1401
|
+
effects.push(log("warn", `Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`));
|
|
1402
|
+
return {
|
|
1403
|
+
state,
|
|
1404
|
+
effects
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
const { conflicts, safeWrites, localOnly } = event;
|
|
1408
|
+
if (safeWrites.length > 0) effects.push(log("info", `Applying ${safeWrites.length} safe writes`), {
|
|
1409
|
+
type: "WRITE_FILES",
|
|
1410
|
+
files: safeWrites
|
|
1411
|
+
});
|
|
1412
|
+
if (localOnly.length > 0) {
|
|
1413
|
+
effects.push(log("info", `Uploading ${localOnly.length} local-only files`));
|
|
1414
|
+
for (const file of localOnly) effects.push({
|
|
1415
|
+
type: "SEND_MESSAGE",
|
|
1416
|
+
payload: {
|
|
1417
|
+
type: "file-change",
|
|
1418
|
+
fileName: file.name,
|
|
1419
|
+
content: file.content
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
if (conflicts.length > 0) {
|
|
1424
|
+
effects.push(log("warn", `${conflicts.length} conflicts require version verification`), log("info", "[CONFLICTS] Requesting remote version data from plugin..."), {
|
|
1425
|
+
type: "REQUEST_CONFLICT_VERSIONS",
|
|
1426
|
+
conflicts
|
|
1427
|
+
});
|
|
1428
|
+
return {
|
|
1429
|
+
state: {
|
|
1430
|
+
...state,
|
|
1431
|
+
mode: "conflict_resolution",
|
|
1432
|
+
pendingConflicts: conflicts
|
|
1433
|
+
},
|
|
1434
|
+
effects
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
effects.push(log("info", "Initial sync complete, entering watch mode"), { type: "PERSIST_STATE" });
|
|
1438
|
+
return {
|
|
1439
|
+
state: {
|
|
1440
|
+
...state,
|
|
1441
|
+
mode: "watching",
|
|
1442
|
+
queuedDiffs: []
|
|
1443
|
+
},
|
|
1444
|
+
effects
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
case "FILE_CHANGE": {
|
|
1448
|
+
const validation = validateIncomingChange(event.file, event.fileMeta, state.mode);
|
|
1449
|
+
if (validation.action === "queue") {
|
|
1450
|
+
effects.push(log("debug", `Queueing file change: ${event.file.name} (${validation.reason})`));
|
|
1451
|
+
return {
|
|
1452
|
+
state: {
|
|
1453
|
+
...state,
|
|
1454
|
+
queuedDiffs: [...state.queuedDiffs, event.file]
|
|
1455
|
+
},
|
|
1456
|
+
effects
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
if (validation.action === "reject") {
|
|
1460
|
+
effects.push(log("warn", `Rejected file change: ${event.file.name} (${validation.reason})`));
|
|
1461
|
+
return {
|
|
1462
|
+
state,
|
|
1463
|
+
effects
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
effects.push(log("info", `Applying remote change: ${event.file.name}`), {
|
|
1467
|
+
type: "WRITE_FILES",
|
|
1468
|
+
files: [event.file]
|
|
1469
|
+
});
|
|
1470
|
+
return {
|
|
1471
|
+
state,
|
|
1472
|
+
effects
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
case "REMOTE_FILE_DELETE": {
|
|
1476
|
+
if (state.mode === "disconnected") {
|
|
1477
|
+
effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
|
|
1478
|
+
return {
|
|
1479
|
+
state,
|
|
1480
|
+
effects
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
effects.push(log("info", `Remote delete applied: ${event.fileName}`), {
|
|
1484
|
+
type: "DELETE_LOCAL_FILES",
|
|
1485
|
+
names: [event.fileName]
|
|
1486
|
+
}, { type: "PERSIST_STATE" });
|
|
1487
|
+
return {
|
|
1488
|
+
state,
|
|
1489
|
+
effects
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
case "REMOTE_DELETE_CONFIRMED": {
|
|
1493
|
+
effects.push(log("info", `Delete confirmed: ${event.fileName}`), {
|
|
1494
|
+
type: "DELETE_LOCAL_FILES",
|
|
1495
|
+
names: [event.fileName]
|
|
1496
|
+
}, { type: "PERSIST_STATE" });
|
|
1497
|
+
return {
|
|
1498
|
+
state,
|
|
1499
|
+
effects
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
case "REMOTE_DELETE_CANCELLED": {
|
|
1503
|
+
effects.push(log("info", `Delete cancelled: ${event.fileName}`));
|
|
1504
|
+
effects.push({
|
|
1505
|
+
type: "WRITE_FILES",
|
|
1506
|
+
files: [{
|
|
1507
|
+
name: event.fileName,
|
|
1508
|
+
content: event.content,
|
|
1509
|
+
modifiedAt: Date.now()
|
|
1510
|
+
}]
|
|
1511
|
+
});
|
|
1512
|
+
return {
|
|
1513
|
+
state,
|
|
1514
|
+
effects
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
case "CONFLICTS_RESOLVED": {
|
|
1518
|
+
if (state.mode !== "conflict_resolution") {
|
|
1519
|
+
effects.push(log("warn", `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`));
|
|
1520
|
+
return {
|
|
1521
|
+
state,
|
|
1522
|
+
effects
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
if (event.resolution === "remote") {
|
|
1526
|
+
const remoteFiles = state.pendingConflicts.map((c) => ({
|
|
1527
|
+
name: c.fileName,
|
|
1528
|
+
content: c.remoteContent,
|
|
1529
|
+
modifiedAt: c.remoteModifiedAt
|
|
1530
|
+
}));
|
|
1531
|
+
effects.push(log("info", `Applying ${remoteFiles.length} remote versions`), {
|
|
1532
|
+
type: "WRITE_FILES",
|
|
1533
|
+
files: remoteFiles
|
|
1534
|
+
});
|
|
1535
|
+
} else {
|
|
1536
|
+
effects.push(log("info", `Applying ${state.pendingConflicts.length} local versions`));
|
|
1537
|
+
for (const conflict of state.pendingConflicts) effects.push({
|
|
1538
|
+
type: "SEND_MESSAGE",
|
|
1539
|
+
payload: {
|
|
1540
|
+
type: "file-change",
|
|
1541
|
+
fileName: conflict.fileName,
|
|
1542
|
+
content: conflict.localContent
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
effects.push(log("info", "All conflicts resolved, entering watch mode"), { type: "PERSIST_STATE" });
|
|
1547
|
+
const { pendingConflicts: _discarded,...rest } = state;
|
|
1548
|
+
return {
|
|
1549
|
+
state: {
|
|
1550
|
+
...rest,
|
|
1551
|
+
mode: "watching"
|
|
1552
|
+
},
|
|
1553
|
+
effects
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
case "WATCHER_EVENT": {
|
|
1557
|
+
const { kind, relativePath, content } = event.event;
|
|
1558
|
+
if (state.mode !== "watching") {
|
|
1559
|
+
effects.push(log("debug", `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}`));
|
|
1560
|
+
return {
|
|
1561
|
+
state,
|
|
1562
|
+
effects
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
if (!state.socket) {
|
|
1566
|
+
effects.push(log("debug", `Ignoring watcher event (disconnected): ${kind} ${relativePath}`));
|
|
1567
|
+
return {
|
|
1568
|
+
state,
|
|
1569
|
+
effects
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
switch (kind) {
|
|
1573
|
+
case "add":
|
|
1574
|
+
case "change": {
|
|
1575
|
+
if (content === void 0) {
|
|
1576
|
+
effects.push(log("warn", `Watcher event missing content: ${relativePath}`));
|
|
1577
|
+
return {
|
|
1578
|
+
state,
|
|
1579
|
+
effects
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
effects.push(log("info", `Local change detected: ${relativePath}`));
|
|
1583
|
+
effects.push({
|
|
1584
|
+
type: "SEND_LOCAL_CHANGE",
|
|
1585
|
+
fileName: relativePath,
|
|
1586
|
+
content
|
|
1587
|
+
});
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
case "delete": {
|
|
1591
|
+
effects.push(log("info", `Local delete detected: ${relativePath}`), {
|
|
1592
|
+
type: "REQUEST_LOCAL_DELETE_DECISION",
|
|
1593
|
+
fileName: relativePath,
|
|
1594
|
+
requireConfirmation: true
|
|
1595
|
+
});
|
|
1596
|
+
break;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
state,
|
|
1601
|
+
effects
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
case "CONFLICT_VERSION_RESPONSE": {
|
|
1605
|
+
if (state.mode !== "conflict_resolution") {
|
|
1606
|
+
effects.push(log("warn", `Received CONFLICT_VERSION_RESPONSE in mode ${state.mode}, ignoring`));
|
|
1607
|
+
return {
|
|
1608
|
+
state,
|
|
1609
|
+
effects
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
|
|
1613
|
+
if (autoResolvedLocal.length > 0) {
|
|
1614
|
+
effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`));
|
|
1615
|
+
for (const conflict of autoResolvedLocal) effects.push({
|
|
1616
|
+
type: "SEND_LOCAL_CHANGE",
|
|
1617
|
+
fileName: conflict.fileName,
|
|
1618
|
+
content: conflict.localContent
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
if (autoResolvedRemote.length > 0) effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`), {
|
|
1622
|
+
type: "WRITE_FILES",
|
|
1623
|
+
files: autoResolvedRemote.map((conflict) => ({
|
|
1624
|
+
name: conflict.fileName,
|
|
1625
|
+
content: conflict.remoteContent,
|
|
1626
|
+
modifiedAt: conflict.remoteModifiedAt ?? Date.now()
|
|
1627
|
+
}))
|
|
1628
|
+
});
|
|
1629
|
+
if (remainingConflicts.length > 0) {
|
|
1630
|
+
effects.push(log("warn", `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`), {
|
|
1631
|
+
type: "REQUEST_CONFLICT_DECISIONS",
|
|
1632
|
+
conflicts: remainingConflicts
|
|
1633
|
+
});
|
|
1634
|
+
return {
|
|
1635
|
+
state: {
|
|
1636
|
+
...state,
|
|
1637
|
+
pendingConflicts: remainingConflicts
|
|
1638
|
+
},
|
|
1639
|
+
effects
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), { type: "PERSIST_STATE" });
|
|
1643
|
+
const { pendingConflicts: _discarded,...rest } = state;
|
|
1644
|
+
return {
|
|
1645
|
+
state: {
|
|
1646
|
+
...rest,
|
|
1647
|
+
mode: "watching",
|
|
1648
|
+
queuedDiffs: []
|
|
1649
|
+
},
|
|
1650
|
+
effects
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
default: {
|
|
1654
|
+
effects.push(log("warn", `Unhandled event type in transition`));
|
|
1655
|
+
return {
|
|
1656
|
+
state,
|
|
1657
|
+
effects
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Effect executor - interprets effects and calls helpers
|
|
1664
|
+
* Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
|
|
1665
|
+
*/
|
|
1666
|
+
async function executeEffect(effect, context) {
|
|
1667
|
+
const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context;
|
|
1668
|
+
switch (effect.type) {
|
|
1669
|
+
case "INIT_WORKSPACE": {
|
|
1670
|
+
if (!config.projectDir) {
|
|
1671
|
+
const projectName = config.explicitName ?? effect.projectInfo.projectName;
|
|
1672
|
+
config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir);
|
|
1673
|
+
config.filesDir = `${config.projectDir}/files`;
|
|
1674
|
+
info(`Files directory: ${config.filesDir}`);
|
|
1675
|
+
await fs.mkdir(config.filesDir, { recursive: true });
|
|
1676
|
+
}
|
|
1677
|
+
return [];
|
|
1678
|
+
}
|
|
1679
|
+
case "LOAD_PERSISTED_STATE": {
|
|
1680
|
+
if (config.projectDir) {
|
|
1681
|
+
await fileMetadataCache.initialize(config.projectDir);
|
|
1682
|
+
info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
|
|
1683
|
+
}
|
|
1684
|
+
return [];
|
|
1685
|
+
}
|
|
1686
|
+
case "LIST_LOCAL_FILES": {
|
|
1687
|
+
if (!config.filesDir) return [];
|
|
1688
|
+
const files = await listFiles(config.filesDir);
|
|
1689
|
+
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
1690
|
+
type: "file-list",
|
|
1691
|
+
files
|
|
1692
|
+
});
|
|
1693
|
+
return [];
|
|
1694
|
+
}
|
|
1695
|
+
case "DETECT_CONFLICTS": {
|
|
1696
|
+
if (!config.filesDir) return [];
|
|
1697
|
+
const { conflicts, writes, localOnly } = await detectConflicts(effect.remoteFiles, config.filesDir, { persistedState: fileMetadataCache.getPersistedState() });
|
|
1698
|
+
return [{
|
|
1699
|
+
type: "CONFLICTS_DETECTED",
|
|
1700
|
+
conflicts,
|
|
1701
|
+
safeWrites: writes,
|
|
1702
|
+
localOnly
|
|
1703
|
+
}];
|
|
1704
|
+
}
|
|
1705
|
+
case "SEND_MESSAGE": {
|
|
1706
|
+
if (syncState.socket) await sendMessage(syncState.socket, effect.payload);
|
|
1707
|
+
return [];
|
|
1708
|
+
}
|
|
1709
|
+
case "WRITE_FILES": {
|
|
1710
|
+
if (config.filesDir) {
|
|
1711
|
+
await writeRemoteFiles(effect.files, config.filesDir, hashTracker, installer ?? void 0);
|
|
1712
|
+
for (const file of effect.files) {
|
|
1713
|
+
const remoteTimestamp = file.modifiedAt ?? Date.now();
|
|
1714
|
+
fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
return [];
|
|
1718
|
+
}
|
|
1719
|
+
case "DELETE_LOCAL_FILES": {
|
|
1720
|
+
if (config.filesDir) for (const fileName of effect.names) {
|
|
1721
|
+
await deleteLocalFile(fileName, config.filesDir, hashTracker);
|
|
1722
|
+
fileMetadataCache.recordDelete(fileName);
|
|
1723
|
+
}
|
|
1724
|
+
return [];
|
|
1725
|
+
}
|
|
1726
|
+
case "REQUEST_CONFLICT_DECISIONS": {
|
|
1727
|
+
await userActions.requestConflictDecisions(syncState.socket, effect.conflicts);
|
|
1728
|
+
return [];
|
|
1729
|
+
}
|
|
1730
|
+
case "REQUEST_CONFLICT_VERSIONS": {
|
|
1731
|
+
if (!syncState.socket) {
|
|
1732
|
+
warn("Cannot request conflict versions without active socket");
|
|
1733
|
+
return [];
|
|
1734
|
+
}
|
|
1735
|
+
const persistedState = fileMetadataCache.getPersistedState();
|
|
1736
|
+
const versionRequests = effect.conflicts.map((conflict) => {
|
|
1737
|
+
const persisted = persistedState.get(conflict.fileName);
|
|
1738
|
+
return {
|
|
1739
|
+
fileName: conflict.fileName,
|
|
1740
|
+
lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp
|
|
1741
|
+
};
|
|
1742
|
+
});
|
|
1743
|
+
info(`[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`);
|
|
1744
|
+
await sendMessage(syncState.socket, {
|
|
1745
|
+
type: "conflict-version-request",
|
|
1746
|
+
conflicts: versionRequests
|
|
1747
|
+
});
|
|
1748
|
+
return [];
|
|
1749
|
+
}
|
|
1750
|
+
case "REQUEST_DELETE_CONFIRMATION": {
|
|
1751
|
+
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
1752
|
+
type: "file-delete",
|
|
1753
|
+
fileNames: [effect.fileName],
|
|
1754
|
+
requireConfirmation: effect.requireConfirmation
|
|
1755
|
+
});
|
|
1756
|
+
return [];
|
|
1757
|
+
}
|
|
1758
|
+
case "UPDATE_FILE_METADATA": {
|
|
1759
|
+
if (!config.filesDir || !config.projectDir) return [];
|
|
1760
|
+
const currentContent = await readFileSafe(effect.fileName, config.filesDir);
|
|
1761
|
+
if (currentContent !== null) {
|
|
1762
|
+
const contentHash = hashFileContent(currentContent);
|
|
1763
|
+
fileMetadataCache.recordSyncedSnapshot(effect.fileName, contentHash, effect.remoteModifiedAt);
|
|
1764
|
+
}
|
|
1765
|
+
return [];
|
|
1766
|
+
}
|
|
1767
|
+
case "SEND_LOCAL_CHANGE": {
|
|
1768
|
+
if (hashTracker.shouldSkip(effect.fileName, effect.content)) return [];
|
|
1769
|
+
try {
|
|
1770
|
+
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
1771
|
+
type: "file-change",
|
|
1772
|
+
fileName: effect.fileName,
|
|
1773
|
+
content: effect.content
|
|
1774
|
+
});
|
|
1775
|
+
hashTracker.remember(effect.fileName, effect.content);
|
|
1776
|
+
if (installer) installer.process(effect.fileName, effect.content);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
console.warn(`Failed to push change for ${effect.fileName}, will re-sync on next diff:`, err);
|
|
1779
|
+
}
|
|
1780
|
+
return [];
|
|
1781
|
+
}
|
|
1782
|
+
case "REQUEST_LOCAL_DELETE_DECISION": {
|
|
1783
|
+
const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName);
|
|
1784
|
+
if (shouldSkip) {
|
|
1785
|
+
hashTracker.clearDelete(effect.fileName);
|
|
1786
|
+
return [];
|
|
1787
|
+
}
|
|
1788
|
+
try {
|
|
1789
|
+
const shouldDelete = await userActions.requestDeleteDecision(syncState.socket, {
|
|
1790
|
+
fileName: effect.fileName,
|
|
1791
|
+
requireConfirmation: !config.dangerouslyAutoDelete
|
|
1792
|
+
});
|
|
1793
|
+
if (shouldDelete) {
|
|
1794
|
+
hashTracker.forget(effect.fileName);
|
|
1795
|
+
fileMetadataCache.recordDelete(effect.fileName);
|
|
1796
|
+
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
1797
|
+
type: "file-delete",
|
|
1798
|
+
fileNames: [effect.fileName]
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
console.warn(`Failed to handle deletion for ${effect.fileName}:`, err);
|
|
1803
|
+
}
|
|
1804
|
+
return [];
|
|
1805
|
+
}
|
|
1806
|
+
case "PERSIST_STATE": {
|
|
1807
|
+
await fileMetadataCache.flush();
|
|
1808
|
+
return [];
|
|
1809
|
+
}
|
|
1810
|
+
case "LOG": {
|
|
1811
|
+
const logFn = effect.level === "info" ? info : effect.level === "warn" ? warn : debug;
|
|
1812
|
+
logFn(effect.message);
|
|
1813
|
+
return [];
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Starts the sync controller with the given configuration
|
|
1819
|
+
*/
|
|
1820
|
+
async function start(config) {
|
|
1821
|
+
info("🚀 Starting Framer Code Link CLI (Next Gen)");
|
|
1822
|
+
info(`Project: ${config.projectHash}`);
|
|
1823
|
+
info(`Port: ${config.port} (auto-selected from project hash)`);
|
|
1824
|
+
const hashTracker = createHashTracker();
|
|
1825
|
+
const fileMetadataCache = new FileMetadataCache();
|
|
1826
|
+
let installer = null;
|
|
1827
|
+
const syncState = {
|
|
1828
|
+
mode: "disconnected",
|
|
1829
|
+
socket: null,
|
|
1830
|
+
queuedDiffs: [],
|
|
1831
|
+
pendingOperations: /* @__PURE__ */ new Map(),
|
|
1832
|
+
nextOperationId: 1
|
|
1833
|
+
};
|
|
1834
|
+
const userActions = new UserActionCoordinator();
|
|
1835
|
+
async function processEvent(event) {
|
|
1836
|
+
const result = transition(syncState, event);
|
|
1837
|
+
Object.assign(syncState, result.state);
|
|
1838
|
+
for (const effect of result.effects) {
|
|
1839
|
+
const followUpEvents = await executeEffect(effect, {
|
|
1840
|
+
config,
|
|
1841
|
+
hashTracker,
|
|
1842
|
+
installer,
|
|
1843
|
+
fileMetadataCache,
|
|
1844
|
+
userActions,
|
|
1845
|
+
syncState
|
|
1846
|
+
});
|
|
1847
|
+
for (const followUpEvent of followUpEvents) await processEvent(followUpEvent);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const connection = initConnection(config.port);
|
|
1851
|
+
connection.on("handshake", async (client, message) => {
|
|
1852
|
+
info("Received handshake from plugin");
|
|
1853
|
+
info(`Project: ${message.projectName} (${message.projectId})`);
|
|
1854
|
+
if (message.projectId !== config.projectHash) {
|
|
1855
|
+
warn(`Project ID mismatch: expected ${config.projectHash}, got ${message.projectId}`);
|
|
1856
|
+
client.close();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
await processEvent({
|
|
1860
|
+
type: "HANDSHAKE",
|
|
1861
|
+
socket: client,
|
|
1862
|
+
projectInfo: {
|
|
1863
|
+
projectId: message.projectId,
|
|
1864
|
+
projectName: message.projectName
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
if (config.projectDir && !installer) {
|
|
1868
|
+
installer = new Installer({ projectDir: config.projectDir });
|
|
1869
|
+
await installer.initialize();
|
|
1870
|
+
startWatcher();
|
|
1871
|
+
}
|
|
1872
|
+
success("Handshake successful - connection established");
|
|
1873
|
+
});
|
|
1874
|
+
async function handleMessage(message) {
|
|
1875
|
+
if (!config.projectDir || !installer) {
|
|
1876
|
+
warn("Received message before handshake completed - ignoring");
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
let event = null;
|
|
1880
|
+
switch (message.type) {
|
|
1881
|
+
case "request-files":
|
|
1882
|
+
event = { type: "REQUEST_FILES" };
|
|
1883
|
+
break;
|
|
1884
|
+
case "file-list":
|
|
1885
|
+
event = {
|
|
1886
|
+
type: "FILE_LIST",
|
|
1887
|
+
files: message.files
|
|
1888
|
+
};
|
|
1889
|
+
break;
|
|
1890
|
+
case "file-change":
|
|
1891
|
+
event = {
|
|
1892
|
+
type: "FILE_CHANGE",
|
|
1893
|
+
file: {
|
|
1894
|
+
name: message.fileName,
|
|
1895
|
+
content: message.content,
|
|
1896
|
+
modifiedAt: Date.now()
|
|
1897
|
+
},
|
|
1898
|
+
fileMeta: fileMetadataCache.get(message.fileName)
|
|
1899
|
+
};
|
|
1900
|
+
break;
|
|
1901
|
+
case "file-delete": {
|
|
1902
|
+
for (const fileName of message.fileNames) await processEvent({
|
|
1903
|
+
type: "REMOTE_FILE_DELETE",
|
|
1904
|
+
fileName
|
|
1905
|
+
});
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
case "delete-confirmed": {
|
|
1909
|
+
const unmatched = [];
|
|
1910
|
+
for (const fileName of message.fileNames) {
|
|
1911
|
+
const handled = userActions.handleConfirmation(`delete:${fileName}`, true);
|
|
1912
|
+
if (!handled) unmatched.push(fileName);
|
|
1913
|
+
}
|
|
1914
|
+
for (const fileName of unmatched) await processEvent({
|
|
1915
|
+
type: "REMOTE_DELETE_CONFIRMED",
|
|
1916
|
+
fileName
|
|
1917
|
+
});
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
case "delete-cancelled": {
|
|
1921
|
+
for (const file of message.files) {
|
|
1922
|
+
userActions.handleConfirmation(`delete:${file.fileName}`, false);
|
|
1923
|
+
await processEvent({
|
|
1924
|
+
type: "REMOTE_DELETE_CANCELLED",
|
|
1925
|
+
fileName: file.fileName,
|
|
1926
|
+
content: file.content ?? ""
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
case "file-synced":
|
|
1932
|
+
event = {
|
|
1933
|
+
type: "FILE_SYNCED",
|
|
1934
|
+
fileName: message.fileName,
|
|
1935
|
+
remoteModifiedAt: message.remoteModifiedAt
|
|
1936
|
+
};
|
|
1937
|
+
break;
|
|
1938
|
+
case "conflicts-resolved":
|
|
1939
|
+
event = {
|
|
1940
|
+
type: "CONFLICTS_RESOLVED",
|
|
1941
|
+
resolution: message.resolution
|
|
1942
|
+
};
|
|
1943
|
+
break;
|
|
1944
|
+
case "conflict-version-response":
|
|
1945
|
+
event = {
|
|
1946
|
+
type: "CONFLICT_VERSION_RESPONSE",
|
|
1947
|
+
versions: message.versions
|
|
1948
|
+
};
|
|
1949
|
+
break;
|
|
1950
|
+
default:
|
|
1951
|
+
warn(`Unhandled message type: ${message.type}`);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (event) await processEvent(event);
|
|
1955
|
+
}
|
|
1956
|
+
connection.on("message", async (message) => {
|
|
1957
|
+
try {
|
|
1958
|
+
await handleMessage(message);
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
error("Error handling message:", err);
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1963
|
+
connection.on("disconnect", async () => {
|
|
1964
|
+
warn("Plugin disconnected");
|
|
1965
|
+
await processEvent({ type: "DISCONNECT" });
|
|
1966
|
+
userActions.cleanup();
|
|
1967
|
+
info("Will perform full diff on reconnect");
|
|
1968
|
+
});
|
|
1969
|
+
let watcher = null;
|
|
1970
|
+
const startWatcher = () => {
|
|
1971
|
+
if (!config.filesDir || watcher) return;
|
|
1972
|
+
watcher = initWatcher(config.filesDir);
|
|
1973
|
+
watcher.on("change", async (event) => {
|
|
1974
|
+
await processEvent({
|
|
1975
|
+
type: "WATCHER_EVENT",
|
|
1976
|
+
event
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
};
|
|
1980
|
+
info("✓ Controller initialized and ready");
|
|
1981
|
+
info(`Waiting for plugin connection on port ${config.port}...`);
|
|
1982
|
+
process.on("SIGINT", async () => {
|
|
1983
|
+
info("\nShutting down gracefully...");
|
|
1984
|
+
if (watcher) await watcher.close();
|
|
1985
|
+
connection.close();
|
|
1986
|
+
process.exit(0);
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/index.ts
|
|
1992
|
+
const program = new Command();
|
|
1993
|
+
program.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("<projectHash>", "Framer project hash").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").action(async (projectHash, options) => {
|
|
1994
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
1995
|
+
if (options.logLevel) {
|
|
1996
|
+
const levelMap = {
|
|
1997
|
+
debug: LogLevel.DEBUG,
|
|
1998
|
+
info: LogLevel.INFO,
|
|
1999
|
+
warn: LogLevel.WARN,
|
|
2000
|
+
error: LogLevel.ERROR
|
|
2001
|
+
};
|
|
2002
|
+
const level = levelMap[options.logLevel.toLowerCase()];
|
|
2003
|
+
if (level !== void 0) setLogLevel(level);
|
|
2004
|
+
} else if (options.verbose || isDev) setLogLevel(LogLevel.DEBUG);
|
|
2005
|
+
const port = getPortFromHash(projectHash);
|
|
2006
|
+
const config = {
|
|
2007
|
+
port,
|
|
2008
|
+
projectHash,
|
|
2009
|
+
projectDir: null,
|
|
2010
|
+
filesDir: null,
|
|
2011
|
+
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
2012
|
+
explicitDir: options.dir,
|
|
2013
|
+
explicitName: options.name
|
|
2014
|
+
};
|
|
2015
|
+
if (config.dangerouslyAutoDelete) info("⚠️ Auto-delete mode enabled - files will be deleted without confirmation");
|
|
2016
|
+
await start(config);
|
|
2017
|
+
});
|
|
2018
|
+
program.parse();
|
|
2019
|
+
|
|
2020
|
+
//#endregion
|
|
2021
|
+
export { start };
|