framer-code-link 0.4.3 → 0.4.5

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.
Files changed (2) hide show
  1. package/dist/index.mjs +427 -433
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -3,11 +3,11 @@ import { createRequire } from "node:module";
3
3
  import { Command } from "commander";
4
4
  import fs from "fs/promises";
5
5
  import { WebSocketServer } from "ws";
6
- import chokidar from "chokidar";
7
6
  import path from "path";
8
7
  import { createHash } from "crypto";
9
8
  import { setupTypeAcquisition } from "@typescript/ata";
10
9
  import ts from "typescript";
10
+ import chokidar from "chokidar";
11
11
 
12
12
  //#region rolldown:runtime
13
13
  var __create = Object.create;
@@ -36,6 +36,173 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  enumerable: true
37
37
  }) : target, mod));
38
38
 
39
+ //#endregion
40
+ //#region ../code-link-shared/src/hash.ts
41
+ /**
42
+ * Base58 alphabet (no 0/O/I/l to avoid confusion)
43
+ */
44
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
45
+ /**
46
+ * Derive a short, deterministic hash from the full Framer project hash.
47
+ * Uses a simple numeric hash encoded in base58 for compactness.
48
+ * Idempotent: if input is already the target length, returns it unchanged.
49
+ */
50
+ function shortProjectHash(fullHash, length = 8) {
51
+ if (fullHash.length === length) return fullHash;
52
+ let h1 = 0;
53
+ let h2 = 0;
54
+ for (let i = 0; i < fullHash.length; i++) {
55
+ const char = fullHash.charCodeAt(i);
56
+ h1 = Math.imul(h1 ^ char, 2246822507);
57
+ h2 = Math.imul(h2 ^ char, 3266489909);
58
+ }
59
+ h1 ^= h2 >>> 16;
60
+ h2 ^= h1 >>> 13;
61
+ let result = "";
62
+ const combined = [Math.abs(h1), Math.abs(h2)];
63
+ for (const num of combined) {
64
+ let n = num >>> 0;
65
+ while (n > 0 && result.length < length) {
66
+ result += BASE58[n % 58];
67
+ n = Math.floor(n / 58);
68
+ }
69
+ }
70
+ while (result.length < length) result += BASE58[0];
71
+ return result.slice(0, length);
72
+ }
73
+
74
+ //#endregion
75
+ //#region ../code-link-shared/src/paths.ts
76
+ /**
77
+ * File path normalization utilities
78
+ * Framer code files include extensions in their paths (.tsx, .ts, etc.)
79
+ */
80
+ const firstCharacterRegex = /^[a-zA-Z$_]/;
81
+ const remainingCharactersRegex = /[^a-zA-Z0-9$_]/g;
82
+ const onlyDotsRegex = /^\.+$/;
83
+ const tsxExtension = ".tsx";
84
+ var NameType = /* @__PURE__ */ function(NameType$1) {
85
+ NameType$1["Variable"] = "Variable";
86
+ NameType$1["Selector"] = "Selector";
87
+ NameType$1["Directory"] = "Directory";
88
+ return NameType$1;
89
+ }(NameType || {});
90
+ function sanitizedName(type, name) {
91
+ if (!name) return null;
92
+ let validName = name.trim();
93
+ if (validName.length === 0) return null;
94
+ const validFirstChar = type === NameType.Selector ? "_" : "$";
95
+ if (type === NameType.Directory) {
96
+ if (onlyDotsRegex.test(validName)) return null;
97
+ } else if (!firstCharacterRegex.test(validName)) validName = validFirstChar + validName;
98
+ validName = validName.replace(remainingCharactersRegex, "_");
99
+ validName = validName.replace(/_+/g, "_");
100
+ validName = validName.replace(/^\$_/u, validFirstChar);
101
+ return validName;
102
+ }
103
+ function sanitizedVariableName(name) {
104
+ return sanitizedName(NameType.Variable, name);
105
+ }
106
+ function sanitizedDirectoryName(name) {
107
+ return sanitizedName(NameType.Directory, name);
108
+ }
109
+ function capitalizeFirstLetter(str) {
110
+ if (str.length === 0) return str;
111
+ return str.charAt(0).toUpperCase() + str.slice(1);
112
+ }
113
+ function hasValidExtension(fileName) {
114
+ if (fileName.endsWith(".json")) return true;
115
+ return /\.[tj]sx?$/u.test(fileName);
116
+ }
117
+ function splitExtension(fileName) {
118
+ const lastDot = fileName.lastIndexOf(".");
119
+ if (lastDot <= 0) return [fileName, ""];
120
+ return [fileName.slice(0, lastDot), fileName.slice(lastDot + 1)];
121
+ }
122
+ function dirname(filePath) {
123
+ const at = filePath.lastIndexOf("/");
124
+ if (at < 0) return "";
125
+ return filePath.slice(0, at);
126
+ }
127
+ function filename(filePath) {
128
+ const at = filePath.lastIndexOf("/") + 1;
129
+ return filePath.slice(at);
130
+ }
131
+ function pathJoin(...parts) {
132
+ let res = "";
133
+ parts.forEach((part) => {
134
+ while (part.startsWith("/")) part = part.slice(1);
135
+ while (part.endsWith("/")) part = part.slice(0, -1);
136
+ if (part === "") return;
137
+ if (res !== "") res += "/";
138
+ res += part;
139
+ });
140
+ return res;
141
+ }
142
+ function normalizePath(filePath) {
143
+ if (!filePath) return "";
144
+ const isAbsolute = filePath.startsWith("/");
145
+ const segments = filePath.replace(/\\/g, "/").split("/");
146
+ const stack = [];
147
+ for (const segment of segments) {
148
+ if (!segment || segment === ".") continue;
149
+ if (segment === "..") {
150
+ if (stack.length > 0) stack.pop();
151
+ continue;
152
+ }
153
+ stack.push(segment);
154
+ }
155
+ const normalized = stack.join("/");
156
+ if (isAbsolute) return `/${normalized}`;
157
+ return normalized;
158
+ }
159
+ function sanitizeFilePath(input, capitalizeReactComponent = true) {
160
+ const trimmed = input.trim();
161
+ const [inputName, extension] = splitExtension(filename(trimmed));
162
+ const extensionWithDot = extension ? `.${extension}` : "";
163
+ const dirName = dirname(trimmed).split("/").map((part) => sanitizedDirectoryName(part)).filter((part) => Boolean(part)).join("/");
164
+ let name = sanitizedVariableName(inputName) ?? "MyComponent";
165
+ if ((!hasValidExtension(extension) || extension === tsxExtension) && capitalizeReactComponent) name = capitalizeFirstLetter(name);
166
+ return {
167
+ path: pathJoin(dirName, name + extensionWithDot),
168
+ dirName,
169
+ name,
170
+ extension
171
+ };
172
+ }
173
+ function isSupportedExtension(filePath) {
174
+ return /\.(tsx?|jsx?|json)$/i.test(filePath);
175
+ }
176
+ /**
177
+ * Pluralize a word based on count
178
+ * @example pluralize(1, "file") => "1 file"
179
+ * @example pluralize(3, "file") => "3 files"
180
+ * @example pluralize(0, "conflict") => "0 conflicts"
181
+ */
182
+ function pluralize(count, singular, plural) {
183
+ return `${count} ${count === 1 ? singular : plural ?? `${singular}s`}`;
184
+ }
185
+
186
+ //#endregion
187
+ //#region ../code-link-shared/src/ports.ts
188
+ /**
189
+ * Generate a deterministic port number from a project hash (full or short).
190
+ * Port range: 3847-4096 (250 possible ports)
191
+ * Must match between CLI and plugin.
192
+ *
193
+ * Internally normalizes to the short id so both full and short inputs yield the same port.
194
+ */
195
+ function getPortFromHash(projectHash) {
196
+ const shortId = shortProjectHash(projectHash);
197
+ let hash = 0;
198
+ for (let i = 0; i < shortId.length; i++) {
199
+ const char = shortId.charCodeAt(i);
200
+ hash = (hash << 5) - hash + char;
201
+ hash = hash & hash;
202
+ }
203
+ return 3847 + Math.abs(hash) % 250;
204
+ }
205
+
39
206
  //#endregion
40
207
  //#region ../../node_modules/picocolors/picocolors.js
41
208
  var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -294,11 +461,6 @@ function resetDisconnectState() {
294
461
  //#endregion
295
462
  //#region src/helpers/connection.ts
296
463
  /**
297
- * WebSocket connection helper
298
- *
299
- * Wrapper around ws.Server that normalizes handshake and surfaces callbacks.
300
- */
301
- /**
302
464
  * Initializes a WebSocket server and returns a connection interface
303
465
  * Returns a Promise that resolves when the server is ready, or rejects on startup errors
304
466
  */
@@ -419,283 +581,48 @@ function sendMessage(socket, message) {
419
581
  }
420
582
 
421
583
  //#endregion
422
- //#region ../code-link-shared/src/hash.ts
584
+ //#region src/utils/node-paths.ts
423
585
  /**
424
- * Base58 alphabet (no 0/O/I/l to avoid confusion)
586
+ * Path manipulation utilities
425
587
  */
426
- const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
427
588
  /**
428
- * Derive a short, deterministic hash from the full Framer project hash.
429
- * Uses a simple numeric hash encoded in base58 for compactness.
430
- * Idempotent: if input is already the target length, returns it unchanged.
589
+ * Gets a relative path from the project directory
431
590
  */
432
- function shortProjectHash(fullHash, length = 8) {
433
- if (fullHash.length === length) return fullHash;
434
- let h1 = 0;
435
- let h2 = 0;
436
- for (let i = 0; i < fullHash.length; i++) {
437
- const char = fullHash.charCodeAt(i);
438
- h1 = Math.imul(h1 ^ char, 2246822507);
439
- h2 = Math.imul(h2 ^ char, 3266489909);
440
- }
441
- h1 ^= h2 >>> 16;
442
- h2 ^= h1 >>> 13;
443
- let result = "";
444
- const combined = [Math.abs(h1), Math.abs(h2)];
445
- for (const num of combined) {
446
- let n = num >>> 0;
447
- while (n > 0 && result.length < length) {
448
- result += BASE58[n % 58];
449
- n = Math.floor(n / 58);
450
- }
451
- }
452
- while (result.length < length) result += BASE58[0];
453
- return result.slice(0, length);
591
+ function getRelativePath(projectDir, absolutePath) {
592
+ return path.relative(projectDir, absolutePath);
454
593
  }
455
-
456
- //#endregion
457
- //#region ../code-link-shared/src/ports.ts
458
594
  /**
459
- * Generate a deterministic port number from a project hash (full or short).
460
- * Port range: 3847-4096 (250 possible ports)
461
- * Must match between CLI and plugin.
462
- *
463
- * Internally normalizes to the short id so both full and short inputs yield the same port.
595
+ * Normalizes a file path by:
596
+ * - Converting backslashes to forward slashes
597
+ * - Resolving . and .. segments
598
+ * - Removing duplicate slashes
464
599
  */
465
- function getPortFromHash(projectHash) {
466
- const shortId = shortProjectHash(projectHash);
467
- let hash = 0;
468
- for (let i = 0; i < shortId.length; i++) {
469
- const char = shortId.charCodeAt(i);
470
- hash = (hash << 5) - hash + char;
471
- hash = hash & hash;
600
+ function normalizePath$1(filePath) {
601
+ if (!filePath) return "";
602
+ const isAbsolute = filePath.startsWith("/");
603
+ const segments = filePath.replace(/\\/g, "/").split("/");
604
+ const stack = [];
605
+ for (const segment of segments) {
606
+ if (!segment || segment === ".") continue;
607
+ if (segment === "..") {
608
+ if (stack.length > 0) stack.pop();
609
+ continue;
610
+ }
611
+ stack.push(segment);
472
612
  }
473
- return 3847 + Math.abs(hash) % 250;
613
+ const normalized = stack.join("/");
614
+ if (isAbsolute) return `/${normalized}`;
615
+ return normalized;
474
616
  }
475
617
 
476
618
  //#endregion
477
- //#region ../code-link-shared/src/paths.ts
619
+ //#region src/utils/state-persistence.ts
478
620
  /**
479
- * File path normalization utilities
480
- * Framer code files include extensions in their paths (.tsx, .ts, etc.)
481
- */
482
- const firstCharacterRegex = /^[a-zA-Z$_]/;
483
- const remainingCharactersRegex = /[^a-zA-Z0-9$_]/g;
484
- const onlyDotsRegex = /^\.+$/;
485
- const tsxExtension = ".tsx";
486
- var NameType = /* @__PURE__ */ function(NameType$1) {
487
- NameType$1["Variable"] = "Variable";
488
- NameType$1["Selector"] = "Selector";
489
- NameType$1["Directory"] = "Directory";
490
- return NameType$1;
491
- }(NameType || {});
492
- function sanitizedName(type, name) {
493
- if (!name) return null;
494
- let validName = name.trim();
495
- if (validName.length === 0) return null;
496
- const validFirstChar = type === NameType.Selector ? "_" : "$";
497
- if (type === NameType.Directory) {
498
- if (onlyDotsRegex.test(validName)) return null;
499
- } else if (!firstCharacterRegex.test(validName)) validName = validFirstChar + validName;
500
- validName = validName.replace(remainingCharactersRegex, "_");
501
- validName = validName.replace(/_+/g, "_");
502
- validName = validName.replace(/^\$_/u, validFirstChar);
503
- return validName;
504
- }
505
- function sanitizedVariableName(name) {
506
- return sanitizedName(NameType.Variable, name);
507
- }
508
- function sanitizedDirectoryName(name) {
509
- return sanitizedName(NameType.Directory, name);
510
- }
511
- function capitalizeFirstLetter(str) {
512
- if (str.length === 0) return str;
513
- return str.charAt(0).toUpperCase() + str.slice(1);
514
- }
515
- function hasValidExtension(fileName) {
516
- if (fileName.endsWith(".json")) return true;
517
- return /\.[tj]sx?$/u.test(fileName);
518
- }
519
- function splitExtension(fileName) {
520
- const lastDot = fileName.lastIndexOf(".");
521
- if (lastDot <= 0) return [fileName, ""];
522
- return [fileName.slice(0, lastDot), fileName.slice(lastDot + 1)];
523
- }
524
- function dirname(filePath) {
525
- const at = filePath.lastIndexOf("/");
526
- if (at < 0) return "";
527
- return filePath.slice(0, at);
528
- }
529
- function filename(filePath) {
530
- const at = filePath.lastIndexOf("/") + 1;
531
- return filePath.slice(at);
532
- }
533
- function pathJoin(...parts) {
534
- let res = "";
535
- parts.forEach((part) => {
536
- while (part.startsWith("/")) part = part.slice(1);
537
- while (part.endsWith("/")) part = part.slice(0, -1);
538
- if (part === "") return;
539
- if (res !== "") res += "/";
540
- res += part;
541
- });
542
- return res;
543
- }
544
- function normalizePath(filePath) {
545
- if (!filePath) return "";
546
- const isAbsolute = filePath.startsWith("/");
547
- const segments = filePath.replace(/\\/g, "/").split("/");
548
- const stack = [];
549
- for (const segment of segments) {
550
- if (!segment || segment === ".") continue;
551
- if (segment === "..") {
552
- if (stack.length > 0) stack.pop();
553
- continue;
554
- }
555
- stack.push(segment);
556
- }
557
- const normalized = stack.join("/");
558
- if (isAbsolute) return `/${normalized}`;
559
- return normalized;
560
- }
561
- function sanitizeFilePath(input, capitalizeReactComponent = true) {
562
- const trimmed = input.trim();
563
- const [inputName, extension] = splitExtension(filename(trimmed));
564
- const extensionWithDot = extension ? `.${extension}` : "";
565
- const dirName = dirname(trimmed).split("/").map((part) => sanitizedDirectoryName(part)).filter((part) => Boolean(part)).join("/");
566
- let name = sanitizedVariableName(inputName) ?? "MyComponent";
567
- if ((!hasValidExtension(extension) || extension === tsxExtension) && capitalizeReactComponent) name = capitalizeFirstLetter(name);
568
- return {
569
- path: pathJoin(dirName, name + extensionWithDot),
570
- dirName,
571
- name,
572
- extension
573
- };
574
- }
575
- function isSupportedExtension$1(filePath) {
576
- return /\.(tsx?|jsx?|json)$/i.test(filePath);
577
- }
578
- /**
579
- * Pluralize a word based on count
580
- * @example pluralize(1, "file") => "1 file"
581
- * @example pluralize(3, "file") => "3 files"
582
- * @example pluralize(0, "conflict") => "0 conflicts"
583
- */
584
- function pluralize(count, singular, plural) {
585
- return `${count} ${count === 1 ? singular : plural ?? `${singular}s`}`;
586
- }
587
-
588
- //#endregion
589
- //#region src/utils/node-paths.ts
590
- /**
591
- * Path manipulation utilities
592
- */
593
- /**
594
- * Gets a relative path from the project directory
595
- */
596
- function getRelativePath(projectDir, absolutePath) {
597
- return path.relative(projectDir, absolutePath);
598
- }
599
- /**
600
- * Normalizes a file path by:
601
- * - Converting backslashes to forward slashes
602
- * - Resolving . and .. segments
603
- * - Removing duplicate slashes
604
- */
605
- function normalizePath$1(filePath) {
606
- if (!filePath) return "";
607
- const isAbsolute = filePath.startsWith("/");
608
- const segments = filePath.replace(/\\/g, "/").split("/");
609
- const stack = [];
610
- for (const segment of segments) {
611
- if (!segment || segment === ".") continue;
612
- if (segment === "..") {
613
- if (stack.length > 0) stack.pop();
614
- continue;
615
- }
616
- stack.push(segment);
617
- }
618
- const normalized = stack.join("/");
619
- if (isAbsolute) return `/${normalized}`;
620
- return normalized;
621
- }
622
-
623
- //#endregion
624
- //#region src/helpers/watcher.ts
625
- /**
626
- * File watcher helper
627
- *
628
- * Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
629
- */
630
- /**
631
- * Initializes a file watcher for the given directory
632
- */
633
- function initWatcher(filesDir) {
634
- const handlers = [];
635
- const watcher = chokidar.watch(filesDir, {
636
- ignored: /(^|[/\\])\.\./,
637
- persistent: true,
638
- ignoreInitial: false
639
- });
640
- debug(`Watching directory: ${filesDir}`);
641
- const emitEvent = async (kind, absolutePath) => {
642
- if (!isSupportedExtension$1(absolutePath)) return;
643
- const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
644
- const relativePath = sanitizeFilePath(rawRelativePath, false).path;
645
- let effectiveAbsolutePath = absolutePath;
646
- if (relativePath !== rawRelativePath && kind === "add") {
647
- const newAbsolutePath = path.join(filesDir, relativePath);
648
- try {
649
- await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
650
- await fs.rename(absolutePath, newAbsolutePath);
651
- debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
652
- effectiveAbsolutePath = newAbsolutePath;
653
- } catch (err) {
654
- warn(`Failed to rename ${rawRelativePath}`, err);
655
- }
656
- }
657
- let content;
658
- if (kind !== "delete") try {
659
- content = await fs.readFile(effectiveAbsolutePath, "utf-8");
660
- } catch (err) {
661
- debug(`Failed to read file ${relativePath}:`, err);
662
- return;
663
- }
664
- const event = {
665
- kind,
666
- relativePath,
667
- content
668
- };
669
- debug(`Watcher event: ${kind} ${relativePath}`);
670
- for (const handler of handlers) handler(event);
671
- };
672
- watcher.on("add", (filePath) => {
673
- emitEvent("add", filePath);
674
- });
675
- watcher.on("change", (filePath) => {
676
- emitEvent("change", filePath);
677
- });
678
- watcher.on("unlink", (filePath) => {
679
- emitEvent("delete", filePath);
680
- });
681
- return {
682
- on(_event, handler) {
683
- handlers.push(handler);
684
- },
685
- async close() {
686
- await watcher.close();
687
- }
688
- };
689
- }
690
-
691
- //#endregion
692
- //#region src/utils/state-persistence.ts
693
- /**
694
- * State persistence helper
695
- *
696
- * Persists last sync timestamps along with content hashes.
697
- * We only trust persisted timestamps if the file content hasn't changed
698
- * (hash matches), because that means the file wasn't edited while CLI was offline.
621
+ * State persistence helper
622
+ *
623
+ * Persists last sync timestamps along with content hashes.
624
+ * We only trust persisted timestamps if the file content hasn't changed
625
+ * (hash matches), because that means the file wasn't edited while CLI was offline.
699
626
  */
700
627
  const STATE_FILE_NAME = ".framer-sync-state.json";
701
628
  const CURRENT_VERSION = 1;
@@ -803,7 +730,7 @@ async function listFiles(filesDir) {
803
730
  await walk(entryPath);
804
731
  continue;
805
732
  }
806
- if (!isSupportedExtension(entry.name)) continue;
733
+ if (!isSupportedExtension$1(entry.name)) continue;
807
734
  const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
808
735
  try {
809
736
  const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
@@ -1054,7 +981,7 @@ function sanitizeRelativePath(relativePath) {
1054
981
  extension: sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION
1055
982
  };
1056
983
  }
1057
- function isSupportedExtension(fileName) {
984
+ function isSupportedExtension$1(fileName) {
1058
985
  const lower = fileName.toLowerCase();
1059
986
  return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext));
1060
987
  }
@@ -1109,6 +1036,7 @@ function extractPackageFromUrl(url) {
1109
1036
  */
1110
1037
  const FETCH_TIMEOUT_MS = 6e4;
1111
1038
  const MAX_FETCH_RETRIES = 3;
1039
+ const MAX_CONSECUTIVE_FAILURES = 10;
1112
1040
  const REACT_TYPES_VERSION = "18.3.12";
1113
1041
  const REACT_DOM_TYPES_VERSION = "18.3.1";
1114
1042
  const CORE_LIBRARIES = ["framer-motion", "framer"];
@@ -1265,7 +1193,6 @@ var Installer = class {
1265
1193
  return;
1266
1194
  }
1267
1195
  if (/node_modules\/@types\/[^/]+\/index\.d\.ts$/.exec(normalized)) await this.ensureTypesPackageJson(normalized);
1268
- if (normalized.includes("node_modules/@types/react/index.d.ts")) await this.patchReactTypes(destination);
1269
1196
  }
1270
1197
  async ensureTypesPackageJson(normalizedPath) {
1271
1198
  const pkgMatch = /node_modules\/(@types\/[^/]+)\//.exec(normalizedPath);
@@ -1285,17 +1212,6 @@ var Installer = class {
1285
1212
  await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2));
1286
1213
  } catch {}
1287
1214
  }
1288
- async patchReactTypes(destination) {
1289
- try {
1290
- let content = await fs.readFile(destination, "utf-8");
1291
- if (content.includes("function useRef<T = undefined>()")) return;
1292
- const overloadPattern = /function useRef<T>\(initialValue: T \| undefined\): RefObject<T \| undefined>;/;
1293
- if (!content.includes("function useRef<T>(initialValue: T | undefined)")) return;
1294
- content = content.replace(overloadPattern, `function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
1295
- function useRef<T = undefined>(): MutableRefObject<T | undefined>;`);
1296
- await fs.writeFile(destination, content, "utf-8");
1297
- } catch {}
1298
- }
1299
1215
  async ensureTsConfig() {
1300
1216
  const tsconfigPath = path.join(this.projectDir, "tsconfig.json");
1301
1217
  try {
@@ -1438,6 +1354,20 @@ function fixExportTypes(value) {
1438
1354
  if ((value.import ?? value.require) && !value.types) value.types = (value.import ?? value.require)?.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
1439
1355
  return value;
1440
1356
  }
1357
+ /** Tracks consecutive network failures across all fetches */
1358
+ let consecutiveFailures = 0;
1359
+ /** Reset failure counter on successful fetch */
1360
+ function resetFailureCounter() {
1361
+ consecutiveFailures = 0;
1362
+ }
1363
+ /** Check if we should give up due to persistent network issues */
1364
+ function checkFatalFailure(url) {
1365
+ consecutiveFailures++;
1366
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1367
+ error(`Network unavailable - ${MAX_CONSECUTIVE_FAILURES} fetch failures.\n Check your internet connection and try again.\n Last failed URL: ${url}`);
1368
+ process.exit(1);
1369
+ }
1370
+ }
1441
1371
  async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1442
1372
  let urlString;
1443
1373
  if (typeof url === "string") urlString = url;
@@ -1454,11 +1384,13 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1454
1384
  signal: controller.signal
1455
1385
  });
1456
1386
  clearTimeout(timeout);
1387
+ resetFailureCounter();
1457
1388
  return response;
1458
1389
  } catch (err) {
1459
1390
  clearTimeout(timeout);
1460
1391
  const error$1 = err;
1461
1392
  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");
1393
+ if (isRetryable) checkFatalFailure(urlString);
1462
1394
  if (attempt < retries && isRetryable) {
1463
1395
  const delay = attempt * 1e3;
1464
1396
  warn(`Fetch failed (${error$1.cause?.code ?? error$1.message}) for ${urlString}, retrying in ${delay}ms...`);
@@ -1473,148 +1405,14 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1473
1405
  }
1474
1406
 
1475
1407
  //#endregion
1476
- //#region src/utils/hash-tracker.ts
1477
- /**
1478
- * Hash tracking utilities for echo prevention
1479
- *
1480
- * The hash tracker prevents echo loops by remembering content hashes
1481
- * and skipping watcher events for files we just wrote.
1482
- */
1483
- /**
1484
- * Creates a hash tracker instance for echo prevention
1485
- */
1486
- function createHashTracker() {
1487
- const hashes = /* @__PURE__ */ new Map();
1488
- const pendingDeletes = /* @__PURE__ */ new Map();
1489
- return {
1490
- remember(filePath, content) {
1491
- const hash = hashContent(content);
1492
- hashes.set(filePath, hash);
1493
- },
1494
- shouldSkip(filePath, content) {
1495
- const currentHash = hashContent(content);
1496
- return hashes.get(filePath) === currentHash;
1497
- },
1498
- forget(filePath) {
1499
- hashes.delete(filePath);
1500
- },
1501
- clear() {
1502
- hashes.clear();
1503
- },
1504
- markDelete(filePath) {
1505
- const existingTimer = pendingDeletes.get(filePath);
1506
- if (existingTimer) clearTimeout(existingTimer);
1507
- const timeout = setTimeout(() => {
1508
- pendingDeletes.delete(filePath);
1509
- }, 5e3);
1510
- pendingDeletes.set(filePath, timeout);
1511
- },
1512
- shouldSkipDelete(filePath) {
1513
- return pendingDeletes.has(filePath);
1514
- },
1515
- clearDelete(filePath) {
1516
- const timeout = pendingDeletes.get(filePath);
1517
- if (timeout) clearTimeout(timeout);
1518
- pendingDeletes.delete(filePath);
1519
- }
1520
- };
1521
- }
1522
- /**
1523
- * Computes a SHA256 hash of file content for comparison
1524
- */
1525
- function hashContent(content) {
1526
- return createHash("sha256").update(content).digest("hex");
1527
- }
1528
-
1529
- //#endregion
1530
- //#region src/utils/file-metadata-cache.ts
1531
- var FileMetadataCache = class {
1532
- metadata = /* @__PURE__ */ new Map();
1533
- persisted = /* @__PURE__ */ new Map();
1534
- projectDir = null;
1535
- initialized = false;
1536
- pendingPersist = null;
1537
- async initialize(projectDir) {
1538
- if (this.initialized && this.projectDir === projectDir) return;
1539
- this.projectDir = projectDir;
1540
- const loaded = await loadPersistedState(projectDir);
1541
- this.persisted = loaded;
1542
- this.metadata = /* @__PURE__ */ new Map();
1543
- for (const [fileName, state] of loaded.entries()) this.metadata.set(fileName, {
1544
- localHash: state.contentHash,
1545
- lastSyncedHash: state.contentHash,
1546
- lastRemoteTimestamp: state.timestamp
1547
- });
1548
- this.initialized = true;
1549
- }
1550
- get(fileName) {
1551
- return this.metadata.get(fileName);
1552
- }
1553
- has(fileName) {
1554
- return this.metadata.has(fileName);
1555
- }
1556
- size() {
1557
- return this.metadata.size;
1558
- }
1559
- getPersistedState() {
1560
- return this.persisted;
1561
- }
1562
- recordRemoteWrite(fileName, content, remoteModifiedAt) {
1563
- const contentHash = hashFileContent(content);
1564
- this.metadata.set(fileName, {
1565
- localHash: contentHash,
1566
- lastSyncedHash: contentHash,
1567
- lastRemoteTimestamp: remoteModifiedAt
1568
- });
1569
- this.persisted.set(fileName, {
1570
- contentHash,
1571
- timestamp: remoteModifiedAt
1572
- });
1573
- this.schedulePersist();
1574
- }
1575
- recordSyncedSnapshot(fileName, contentHash, remoteModifiedAt) {
1576
- this.metadata.set(fileName, {
1577
- localHash: contentHash,
1578
- lastSyncedHash: contentHash,
1579
- lastRemoteTimestamp: remoteModifiedAt
1580
- });
1581
- this.persisted.set(fileName, {
1582
- contentHash,
1583
- timestamp: remoteModifiedAt
1584
- });
1585
- this.schedulePersist();
1586
- }
1587
- recordDelete(fileName) {
1588
- this.metadata.delete(fileName);
1589
- this.persisted.delete(fileName);
1590
- this.schedulePersist();
1591
- }
1592
- async flush() {
1593
- if (this.pendingPersist) await this.pendingPersist;
1594
- }
1595
- schedulePersist() {
1596
- const projectDir = this.projectDir;
1597
- if (!projectDir) return;
1598
- this.pendingPersist ??= (async () => {
1599
- try {
1600
- await Promise.resolve();
1601
- await savePersistedState(projectDir, this.persisted);
1602
- } finally {
1603
- this.pendingPersist = null;
1604
- }
1605
- })();
1606
- }
1607
- };
1608
-
1609
- //#endregion
1610
- //#region src/helpers/user-actions.ts
1408
+ //#region src/helpers/plugin-prompts.ts
1611
1409
  var PluginDisconnectedError = class extends Error {
1612
1410
  constructor() {
1613
1411
  super("Plugin disconnected");
1614
1412
  this.name = "PluginDisconnectedError";
1615
1413
  }
1616
1414
  };
1617
- var UserActionCoordinator = class {
1415
+ var PluginUserPromptCoordinator = class {
1618
1416
  pendingActions = /* @__PURE__ */ new Map();
1619
1417
  /**
1620
1418
  * Register a pending action and return a typed promise
@@ -1744,6 +1542,208 @@ function validateIncomingChange(fileMeta, currentMode) {
1744
1542
  };
1745
1543
  }
1746
1544
 
1545
+ //#endregion
1546
+ //#region src/helpers/watcher.ts
1547
+ /**
1548
+ * File watcher helper
1549
+ *
1550
+ * Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
1551
+ */
1552
+ /**
1553
+ * Initializes a file watcher for the given directory
1554
+ */
1555
+ function initWatcher(filesDir) {
1556
+ const handlers = [];
1557
+ const watcher = chokidar.watch(filesDir, {
1558
+ ignored: /(^|[/\\])\.\./,
1559
+ persistent: true,
1560
+ ignoreInitial: false
1561
+ });
1562
+ debug(`Watching directory: ${filesDir}`);
1563
+ const emitEvent = async (kind, absolutePath) => {
1564
+ if (!isSupportedExtension(absolutePath)) return;
1565
+ const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
1566
+ const relativePath = sanitizeFilePath(rawRelativePath, false).path;
1567
+ let effectiveAbsolutePath = absolutePath;
1568
+ if (relativePath !== rawRelativePath && kind === "add") {
1569
+ const newAbsolutePath = path.join(filesDir, relativePath);
1570
+ try {
1571
+ await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
1572
+ await fs.rename(absolutePath, newAbsolutePath);
1573
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
1574
+ effectiveAbsolutePath = newAbsolutePath;
1575
+ } catch (err) {
1576
+ warn(`Failed to rename ${rawRelativePath}`, err);
1577
+ }
1578
+ }
1579
+ let content;
1580
+ if (kind !== "delete") try {
1581
+ content = await fs.readFile(effectiveAbsolutePath, "utf-8");
1582
+ } catch (err) {
1583
+ debug(`Failed to read file ${relativePath}:`, err);
1584
+ return;
1585
+ }
1586
+ const event = {
1587
+ kind,
1588
+ relativePath,
1589
+ content
1590
+ };
1591
+ debug(`Watcher event: ${kind} ${relativePath}`);
1592
+ for (const handler of handlers) handler(event);
1593
+ };
1594
+ watcher.on("add", (filePath) => {
1595
+ emitEvent("add", filePath);
1596
+ });
1597
+ watcher.on("change", (filePath) => {
1598
+ emitEvent("change", filePath);
1599
+ });
1600
+ watcher.on("unlink", (filePath) => {
1601
+ emitEvent("delete", filePath);
1602
+ });
1603
+ return {
1604
+ on(_event, handler) {
1605
+ handlers.push(handler);
1606
+ },
1607
+ async close() {
1608
+ await watcher.close();
1609
+ }
1610
+ };
1611
+ }
1612
+
1613
+ //#endregion
1614
+ //#region src/utils/file-metadata-cache.ts
1615
+ var FileMetadataCache = class {
1616
+ metadata = /* @__PURE__ */ new Map();
1617
+ persisted = /* @__PURE__ */ new Map();
1618
+ projectDir = null;
1619
+ initialized = false;
1620
+ pendingPersist = null;
1621
+ async initialize(projectDir) {
1622
+ if (this.initialized && this.projectDir === projectDir) return;
1623
+ this.projectDir = projectDir;
1624
+ const loaded = await loadPersistedState(projectDir);
1625
+ this.persisted = loaded;
1626
+ this.metadata = /* @__PURE__ */ new Map();
1627
+ for (const [fileName, state] of loaded.entries()) this.metadata.set(fileName, {
1628
+ localHash: state.contentHash,
1629
+ lastSyncedHash: state.contentHash,
1630
+ lastRemoteTimestamp: state.timestamp
1631
+ });
1632
+ this.initialized = true;
1633
+ }
1634
+ get(fileName) {
1635
+ return this.metadata.get(fileName);
1636
+ }
1637
+ has(fileName) {
1638
+ return this.metadata.has(fileName);
1639
+ }
1640
+ size() {
1641
+ return this.metadata.size;
1642
+ }
1643
+ getPersistedState() {
1644
+ return this.persisted;
1645
+ }
1646
+ recordRemoteWrite(fileName, content, remoteModifiedAt) {
1647
+ const contentHash = hashFileContent(content);
1648
+ this.metadata.set(fileName, {
1649
+ localHash: contentHash,
1650
+ lastSyncedHash: contentHash,
1651
+ lastRemoteTimestamp: remoteModifiedAt
1652
+ });
1653
+ this.persisted.set(fileName, {
1654
+ contentHash,
1655
+ timestamp: remoteModifiedAt
1656
+ });
1657
+ this.schedulePersist();
1658
+ }
1659
+ recordSyncedSnapshot(fileName, contentHash, remoteModifiedAt) {
1660
+ this.metadata.set(fileName, {
1661
+ localHash: contentHash,
1662
+ lastSyncedHash: contentHash,
1663
+ lastRemoteTimestamp: remoteModifiedAt
1664
+ });
1665
+ this.persisted.set(fileName, {
1666
+ contentHash,
1667
+ timestamp: remoteModifiedAt
1668
+ });
1669
+ this.schedulePersist();
1670
+ }
1671
+ recordDelete(fileName) {
1672
+ this.metadata.delete(fileName);
1673
+ this.persisted.delete(fileName);
1674
+ this.schedulePersist();
1675
+ }
1676
+ async flush() {
1677
+ if (this.pendingPersist) await this.pendingPersist;
1678
+ }
1679
+ schedulePersist() {
1680
+ const projectDir = this.projectDir;
1681
+ if (!projectDir) return;
1682
+ this.pendingPersist ??= (async () => {
1683
+ try {
1684
+ await Promise.resolve();
1685
+ await savePersistedState(projectDir, this.persisted);
1686
+ } finally {
1687
+ this.pendingPersist = null;
1688
+ }
1689
+ })();
1690
+ }
1691
+ };
1692
+
1693
+ //#endregion
1694
+ //#region src/utils/hash-tracker.ts
1695
+ /**
1696
+ * Hash tracking utilities for echo prevention
1697
+ *
1698
+ * The hash tracker prevents echo loops by remembering content hashes
1699
+ * and skipping watcher events for files we just wrote.
1700
+ */
1701
+ /**
1702
+ * Creates a hash tracker instance for echo prevention
1703
+ */
1704
+ function createHashTracker() {
1705
+ const hashes = /* @__PURE__ */ new Map();
1706
+ const pendingDeletes = /* @__PURE__ */ new Map();
1707
+ return {
1708
+ remember(filePath, content) {
1709
+ const hash = hashContent(content);
1710
+ hashes.set(filePath, hash);
1711
+ },
1712
+ shouldSkip(filePath, content) {
1713
+ const currentHash = hashContent(content);
1714
+ return hashes.get(filePath) === currentHash;
1715
+ },
1716
+ forget(filePath) {
1717
+ hashes.delete(filePath);
1718
+ },
1719
+ clear() {
1720
+ hashes.clear();
1721
+ },
1722
+ markDelete(filePath) {
1723
+ const existingTimer = pendingDeletes.get(filePath);
1724
+ if (existingTimer) clearTimeout(existingTimer);
1725
+ const timeout = setTimeout(() => {
1726
+ pendingDeletes.delete(filePath);
1727
+ }, 5e3);
1728
+ pendingDeletes.set(filePath, timeout);
1729
+ },
1730
+ shouldSkipDelete(filePath) {
1731
+ return pendingDeletes.has(filePath);
1732
+ },
1733
+ clearDelete(filePath) {
1734
+ const timeout = pendingDeletes.get(filePath);
1735
+ if (timeout) clearTimeout(timeout);
1736
+ pendingDeletes.delete(filePath);
1737
+ }
1738
+ };
1739
+ }
1740
+ /**
1741
+ * Computes a SHA256 hash of file content for comparison
1742
+ */
1743
+ function hashContent(content) {
1744
+ return createHash("sha256").update(content).digest("hex");
1745
+ }
1746
+
1747
1747
  //#endregion
1748
1748
  //#region src/utils/project.ts
1749
1749
  function toPackageName(name) {
@@ -1810,12 +1810,6 @@ async function matchesProject(packageJsonPath, projectHash) {
1810
1810
 
1811
1811
  //#endregion
1812
1812
  //#region src/controller.ts
1813
- /**
1814
- * CLI Controller
1815
- *
1816
- * All runtime state and orchestrates the sync lifecycle.
1817
- * Helpers should provide data, nevering hold control or callbacks.
1818
- */
1819
1813
  /** Log helper */
1820
1814
  function log(level, message) {
1821
1815
  return {
@@ -1898,9 +1892,9 @@ function transition(state, event) {
1898
1892
  state,
1899
1893
  effects
1900
1894
  };
1901
- case "FILE_LIST":
1895
+ case "REMOTE_FILE_LIST":
1902
1896
  if (state.mode !== "handshaking") {
1903
- effects.push(log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`));
1897
+ effects.push(log("warn", `Received REMOTE_FILE_LIST in mode ${state.mode}, ignoring`));
1904
1898
  return {
1905
1899
  state,
1906
1900
  effects
@@ -1977,7 +1971,7 @@ function transition(state, event) {
1977
1971
  effects
1978
1972
  };
1979
1973
  }
1980
- case "FILE_CHANGE": {
1974
+ case "REMOTE_FILE_CHANGE": {
1981
1975
  const validation = validateIncomingChange(event.fileMeta, state.mode);
1982
1976
  if (validation.action === "queue") {
1983
1977
  effects.push(log("debug", `Queueing file change: ${event.file.name} (${validation.reason})`));
@@ -2413,7 +2407,7 @@ async function start(config) {
2413
2407
  pendingOperations: /* @__PURE__ */ new Map(),
2414
2408
  nextOperationId: 1
2415
2409
  };
2416
- const userActions = new UserActionCoordinator();
2410
+ const userActions = new PluginUserPromptCoordinator();
2417
2411
  async function processEvent(event) {
2418
2412
  const socketState = syncState.socket?.readyState;
2419
2413
  debug(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
@@ -2478,13 +2472,13 @@ async function start(config) {
2478
2472
  case "file-list":
2479
2473
  debug(`Received file list: ${message.files.length} files`);
2480
2474
  event = {
2481
- type: "FILE_LIST",
2475
+ type: "REMOTE_FILE_LIST",
2482
2476
  files: message.files
2483
2477
  };
2484
2478
  break;
2485
2479
  case "file-change":
2486
2480
  event = {
2487
- type: "FILE_CHANGE",
2481
+ type: "REMOTE_FILE_CHANGE",
2488
2482
  file: {
2489
2483
  name: message.fileName,
2490
2484
  content: message.content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.mjs",
6
6
  "type": "module",