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/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 };