framer-code-link 0.4.4 → 0.4.6

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 +487 -480
  2. package/package.json +3 -3
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) {
85
+ NameType["Variable"] = "Variable";
86
+ NameType["Selector"] = "Selector";
87
+ NameType["Directory"] = "Directory";
88
+ return NameType;
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$1(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$1(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) => {
@@ -108,12 +275,12 @@ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
108
275
  //#endregion
109
276
  //#region src/utils/logging.ts
110
277
  var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
111
- let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
112
- LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
113
- LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
114
- LogLevel$1[LogLevel$1["WARN"] = 2] = "WARN";
115
- LogLevel$1[LogLevel$1["ERROR"] = 3] = "ERROR";
116
- return LogLevel$1;
278
+ let LogLevel = /* @__PURE__ */ function(LogLevel) {
279
+ LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
280
+ LogLevel[LogLevel["INFO"] = 1] = "INFO";
281
+ LogLevel[LogLevel["WARN"] = 2] = "WARN";
282
+ LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
283
+ return LogLevel;
117
284
  }({});
118
285
  let currentLevel = LogLevel.INFO;
119
286
  let lastMessage = "";
@@ -158,9 +325,9 @@ function logWithDedupe(message, writer) {
158
325
  /**
159
326
  * Print the startup banner - one colored line
160
327
  */
161
- function banner(version$1, port) {
328
+ function banner(version, port) {
162
329
  console.log();
163
- let message = ` ${import_picocolors.default.cyan(import_picocolors.default.bold("⚡ Code Link"))} ${import_picocolors.default.dim(`v${version$1}`)}`;
330
+ let message = ` ${import_picocolors.default.cyan(import_picocolors.default.bold("⚡ Code Link"))} ${import_picocolors.default.dim(`v${version}`)}`;
164
331
  if (currentLevel <= LogLevel.DEBUG) message += ` ${import_picocolors.default.dim("Port")} ${import_picocolors.default.yellow(port)}`;
165
332
  console.log(message);
166
333
  console.log();
@@ -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,298 +581,63 @@ 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);
591
+ function getRelativePath(projectDir, absolutePath) {
592
+ return path.relative(projectDir, absolutePath);
593
+ }
594
+ /**
595
+ * Normalizes a file path by:
596
+ * - Converting backslashes to forward slashes
597
+ * - Resolving . and .. segments
598
+ * - Removing duplicate slashes
599
+ */
600
+ function normalizePath(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;
450
610
  }
611
+ stack.push(segment);
451
612
  }
452
- while (result.length < length) result += BASE58[0];
453
- return result.slice(0, length);
613
+ const normalized = stack.join("/");
614
+ if (isAbsolute) return `/${normalized}`;
615
+ return normalized;
454
616
  }
455
617
 
456
618
  //#endregion
457
- //#region ../code-link-shared/src/ports.ts
619
+ //#region src/utils/state-persistence.ts
458
620
  /**
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.
621
+ * State persistence helper
462
622
  *
463
- * Internally normalizes to the short id so both full and short inputs yield the same port.
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.
464
626
  */
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;
472
- }
473
- return 3847 + Math.abs(hash) % 250;
474
- }
475
-
476
- //#endregion
477
- //#region ../code-link-shared/src/paths.ts
478
- /**
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.
699
- */
700
- const STATE_FILE_NAME = ".framer-sync-state.json";
701
- const CURRENT_VERSION = 1;
702
- const SUPPORTED_EXTENSIONS$1 = [
703
- ".ts",
704
- ".tsx",
705
- ".js",
706
- ".jsx",
707
- ".json"
708
- ];
709
- const DEFAULT_EXTENSION$1 = ".tsx";
710
- function normalizePersistedFileName(fileName) {
711
- let normalized = normalizePath$1(fileName.trim());
712
- if (!SUPPORTED_EXTENSIONS$1.some((ext) => normalized.toLowerCase().endsWith(ext))) normalized = `${normalized}${DEFAULT_EXTENSION$1}`;
713
- return normalized;
627
+ const STATE_FILE_NAME = ".framer-sync-state.json";
628
+ const CURRENT_VERSION = 1;
629
+ const SUPPORTED_EXTENSIONS$1 = [
630
+ ".ts",
631
+ ".tsx",
632
+ ".js",
633
+ ".jsx",
634
+ ".json"
635
+ ];
636
+ const DEFAULT_EXTENSION$1 = ".tsx";
637
+ function normalizePersistedFileName(fileName) {
638
+ let normalized = normalizePath(fileName.trim());
639
+ if (!SUPPORTED_EXTENSIONS$1.some((ext) => normalized.toLowerCase().endsWith(ext))) normalized = `${normalized}${DEFAULT_EXTENSION$1}`;
640
+ return normalized;
714
641
  }
715
642
  /**
716
643
  * Hash file content to detect changes
@@ -785,7 +712,7 @@ const SUPPORTED_EXTENSIONS = [
785
712
  ".json"
786
713
  ];
787
714
  const DEFAULT_EXTENSION = ".tsx";
788
- const DEFAULT_REMOTE_DRIFT_MS = 2e3;
715
+ const DEFAULT_REMOTE_DRIFT_MS = 2500;
789
716
  /** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
790
717
  function normalizeForComparison(fileName) {
791
718
  return fileName.toLowerCase();
@@ -804,7 +731,7 @@ async function listFiles(filesDir) {
804
731
  continue;
805
732
  }
806
733
  if (!isSupportedExtension(entry.name)) continue;
807
- const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
734
+ const sanitizedPath = sanitizeFilePath(normalizePath$1(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)]);
810
737
  files.push({
@@ -832,7 +759,7 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
832
759
  const detect = options.detectConflicts ?? true;
833
760
  const preferRemote = options.preferRemote ?? false;
834
761
  const persistedState = options.persistedState;
835
- const getPersistedState = (fileName) => persistedState?.get(normalizeForComparison(fileName)) ?? persistedState?.get(fileName);
762
+ const getPersistedState = (fileName) => persistedState?.get(normalizeForComparison(fileName));
836
763
  debug(`Detecting conflicts for ${String(remoteFiles.length)} remote files`);
837
764
  const localFiles = await listFiles(filesDir);
838
765
  const localFileMap = new Map(localFiles.map((f) => [normalizeForComparison(f.name), f]));
@@ -928,7 +855,7 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
928
855
  };
929
856
  }
930
857
  function autoResolveConflicts(conflicts, versions, options = {}) {
931
- const versionMap = new Map(versions.map((version$1) => [version$1.fileName, version$1.latestRemoteVersionMs]));
858
+ const versionMap = new Map(versions.map((version) => [version.fileName, version.latestRemoteVersionMs]));
932
859
  const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS;
933
860
  const autoResolvedLocal = [];
934
861
  const autoResolvedRemote = [];
@@ -948,28 +875,31 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
948
875
  }
949
876
  continue;
950
877
  }
878
+ if (localClean) {
879
+ debug(` Local clean -> REMOTE (safe to overwrite)`);
880
+ autoResolvedRemote.push(conflict);
881
+ continue;
882
+ }
951
883
  if (!latestRemoteVersionMs) {
952
- debug(` No remote version data, keeping conflict`);
884
+ debug(` Local modified, no remote version data -> conflict`);
953
885
  remainingConflicts.push(conflict);
954
886
  continue;
955
887
  }
956
888
  if (!lastSyncedAt) {
957
- debug(` No last sync timestamp, keeping conflict`);
889
+ debug(` Local modified, no sync timestamp -> conflict`);
958
890
  remainingConflicts.push(conflict);
959
891
  continue;
960
892
  }
961
893
  debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`);
962
894
  debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`);
963
895
  const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
964
- if (remoteUnchanged && !localClean) {
896
+ const driftMargin = latestRemoteVersionMs - lastSyncedAt;
897
+ if (remoteUnchanged) {
965
898
  debug(` Remote unchanged, local changed -> LOCAL`);
899
+ if (driftMargin > 0) debug(` (within drift tolerance: ${driftMargin}ms < ${remoteDriftMs}ms threshold)`);
966
900
  autoResolvedLocal.push(conflict);
967
- } else if (localClean && !remoteUnchanged) {
968
- debug(` Local unchanged, remote changed -> REMOTE`);
969
- autoResolvedRemote.push(conflict);
970
- } else if (remoteUnchanged && localClean) debug(` Both unchanged, skipping`);
971
- else {
972
- debug(` Both changed, real conflict`);
901
+ } else {
902
+ debug(` Both changed -> conflict (remote ahead by ${driftMargin}ms, threshold: ${remoteDriftMs}ms)`);
973
903
  remainingConflicts.push(conflict);
974
904
  }
975
905
  }
@@ -1046,9 +976,9 @@ function resolveRemoteReference(filesDir, rawName) {
1046
976
  };
1047
977
  }
1048
978
  function sanitizeRelativePath(relativePath) {
1049
- const trimmed = normalizePath(relativePath.trim());
979
+ const trimmed = normalizePath$1(relativePath.trim());
1050
980
  const sanitized = sanitizeFilePath(SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext)) ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`, false);
1051
- const normalized = normalizePath(sanitized.path);
981
+ const normalized = normalizePath$1(sanitized.path);
1052
982
  return {
1053
983
  relativePath: normalized,
1054
984
  extension: sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION
@@ -1109,6 +1039,7 @@ function extractPackageFromUrl(url) {
1109
1039
  */
1110
1040
  const FETCH_TIMEOUT_MS = 6e4;
1111
1041
  const MAX_FETCH_RETRIES = 3;
1042
+ const MAX_CONSECUTIVE_FAILURES = 10;
1112
1043
  const REACT_TYPES_VERSION = "18.3.12";
1113
1044
  const REACT_DOM_TYPES_VERSION = "18.3.1";
1114
1045
  const CORE_LIBRARIES = ["framer-motion", "framer"];
@@ -1154,8 +1085,8 @@ var Installer = class {
1154
1085
  finished: (files) => {
1155
1086
  if (files.size > 0) debug("ATA: type acquisition complete");
1156
1087
  },
1157
- errorMessage: (message, error$1) => {
1158
- warn(`ATA warning: ${message}`, error$1);
1088
+ errorMessage: (message, error) => {
1089
+ warn(`ATA warning: ${message}`, error);
1159
1090
  },
1160
1091
  receivedFile: (code, receivedPath) => {
1161
1092
  (async () => {
@@ -1265,7 +1196,6 @@ var Installer = class {
1265
1196
  return;
1266
1197
  }
1267
1198
  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
1199
  }
1270
1200
  async ensureTypesPackageJson(normalizedPath) {
1271
1201
  const pkgMatch = /node_modules\/(@types\/[^/]+)\//.exec(normalizedPath);
@@ -1277,25 +1207,14 @@ var Installer = class {
1277
1207
  const response = await fetch(`https://registry.npmjs.org/${pkgName}`);
1278
1208
  if (!response.ok) return;
1279
1209
  const npmData = await response.json();
1280
- const version$1 = npmData["dist-tags"]?.latest;
1281
- if (!version$1 || !npmData.versions?.[version$1]) return;
1282
- const pkg = npmData.versions[version$1];
1210
+ const version = npmData["dist-tags"]?.latest;
1211
+ if (!version || !npmData.versions?.[version]) return;
1212
+ const pkg = npmData.versions[version];
1283
1213
  if (pkg.exports) for (const key of Object.keys(pkg.exports)) pkg.exports[key] = fixExportTypes(pkg.exports[key]);
1284
1214
  await fs.mkdir(pkgDir, { recursive: true });
1285
1215
  await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2));
1286
1216
  } catch {}
1287
1217
  }
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
1218
  async ensureTsConfig() {
1300
1219
  const tsconfigPath = path.join(this.projectDir, "tsconfig.json");
1301
1220
  try {
@@ -1398,11 +1317,11 @@ declare module "*.json"
1398
1317
  if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) debug("📦 React DOM types (from cache)");
1399
1318
  else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
1400
1319
  }
1401
- async hasTypePackage(destinationDir, version$1, files) {
1320
+ async hasTypePackage(destinationDir, version, files) {
1402
1321
  try {
1403
1322
  const pkgJsonPath = path.join(destinationDir, "package.json");
1404
1323
  const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
1405
- if (JSON.parse(pkgJson).version !== version$1) return false;
1324
+ if (JSON.parse(pkgJson).version !== version) return false;
1406
1325
  for (const file of files) {
1407
1326
  if (file === "package.json") continue;
1408
1327
  await fs.access(path.join(destinationDir, file));
@@ -1412,8 +1331,8 @@ declare module "*.json"
1412
1331
  return false;
1413
1332
  }
1414
1333
  }
1415
- async downloadTypePackage(pkgName, version$1, destinationDir, files) {
1416
- const baseUrl = `https://unpkg.com/${pkgName}@${version$1}`;
1334
+ async downloadTypePackage(pkgName, version, destinationDir, files) {
1335
+ const baseUrl = `https://unpkg.com/${pkgName}@${version}`;
1417
1336
  await fs.mkdir(destinationDir, { recursive: true });
1418
1337
  await Promise.all(files.map(async (file) => {
1419
1338
  const destination = path.join(destinationDir, file);
@@ -1438,6 +1357,20 @@ function fixExportTypes(value) {
1438
1357
  if ((value.import ?? value.require) && !value.types) value.types = (value.import ?? value.require)?.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
1439
1358
  return value;
1440
1359
  }
1360
+ /** Tracks consecutive network failures across all fetches */
1361
+ let consecutiveFailures = 0;
1362
+ /** Reset failure counter on successful fetch */
1363
+ function resetFailureCounter() {
1364
+ consecutiveFailures = 0;
1365
+ }
1366
+ /** Check if we should give up due to persistent network issues */
1367
+ function checkFatalFailure(url) {
1368
+ consecutiveFailures++;
1369
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1370
+ error(`Network unavailable - ${MAX_CONSECUTIVE_FAILURES} fetch failures.\n Check your internet connection and try again.\n Last failed URL: ${url}`);
1371
+ process.exit(1);
1372
+ }
1373
+ }
1441
1374
  async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1442
1375
  let urlString;
1443
1376
  if (typeof url === "string") urlString = url;
@@ -1454,158 +1387,26 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1454
1387
  signal: controller.signal
1455
1388
  });
1456
1389
  clearTimeout(timeout);
1390
+ resetFailureCounter();
1457
1391
  return response;
1458
1392
  } catch (err) {
1459
1393
  clearTimeout(timeout);
1460
- const error$1 = err;
1461
- 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");
1394
+ const error = err;
1395
+ const isRetryable = error.cause?.code === "ECONNRESET" || error.cause?.code === "ETIMEDOUT" || error.cause?.code === "UND_ERR_CONNECT_TIMEOUT" || error.message.includes("timeout");
1396
+ if (isRetryable) checkFatalFailure(urlString);
1462
1397
  if (attempt < retries && isRetryable) {
1463
1398
  const delay = attempt * 1e3;
1464
- warn(`Fetch failed (${error$1.cause?.code ?? error$1.message}) for ${urlString}, retrying in ${delay}ms...`);
1399
+ warn(`Fetch failed (${error.cause?.code ?? error.message}) for ${urlString}, retrying in ${delay}ms...`);
1465
1400
  await new Promise((resolve) => setTimeout(resolve, delay));
1466
1401
  continue;
1467
1402
  }
1468
- warn(`Fetch failed for ${urlString}`, error$1);
1469
- throw error$1;
1403
+ warn(`Fetch failed for ${urlString}`, error);
1404
+ throw error;
1470
1405
  }
1471
1406
  }
1472
1407
  throw new Error(`Max retries exceeded for ${urlString}`);
1473
1408
  }
1474
1409
 
1475
- //#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
1410
  //#endregion
1610
1411
  //#region src/helpers/plugin-prompts.ts
1611
1412
  var PluginDisconnectedError = class extends Error {
@@ -1744,6 +1545,218 @@ function validateIncomingChange(fileMeta, currentMode) {
1744
1545
  };
1745
1546
  }
1746
1547
 
1548
+ //#endregion
1549
+ //#region src/helpers/watcher.ts
1550
+ /**
1551
+ * File watcher helper
1552
+ *
1553
+ * Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
1554
+ */
1555
+ /**
1556
+ * Initializes a file watcher for the given directory
1557
+ */
1558
+ function initWatcher(filesDir) {
1559
+ const handlers = [];
1560
+ const watcher = chokidar.watch(filesDir, {
1561
+ ignored: /(^|[/\\])\.\./,
1562
+ persistent: true,
1563
+ ignoreInitial: false
1564
+ });
1565
+ debug(`Watching directory: ${filesDir}`);
1566
+ const emitEvent = async (kind, absolutePath) => {
1567
+ if (!isSupportedExtension$1(absolutePath)) return;
1568
+ const rawRelativePath = normalizePath$1(getRelativePath(filesDir, absolutePath));
1569
+ const relativePath = sanitizeFilePath(rawRelativePath, false).path;
1570
+ let effectiveAbsolutePath = absolutePath;
1571
+ if (relativePath !== rawRelativePath && kind === "add") {
1572
+ const newAbsolutePath = path.join(filesDir, relativePath);
1573
+ try {
1574
+ await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
1575
+ await fs.rename(absolutePath, newAbsolutePath);
1576
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
1577
+ effectiveAbsolutePath = newAbsolutePath;
1578
+ } catch (err) {
1579
+ warn(`Failed to rename ${rawRelativePath}`, err);
1580
+ }
1581
+ }
1582
+ let content;
1583
+ if (kind !== "delete") try {
1584
+ content = await fs.readFile(effectiveAbsolutePath, "utf-8");
1585
+ } catch (err) {
1586
+ debug(`Failed to read file ${relativePath}:`, err);
1587
+ return;
1588
+ }
1589
+ const event = {
1590
+ kind,
1591
+ relativePath,
1592
+ content
1593
+ };
1594
+ debug(`Watcher event: ${kind} ${relativePath}`);
1595
+ for (const handler of handlers) handler(event);
1596
+ };
1597
+ watcher.on("add", (filePath) => {
1598
+ emitEvent("add", filePath);
1599
+ });
1600
+ watcher.on("change", (filePath) => {
1601
+ emitEvent("change", filePath);
1602
+ });
1603
+ watcher.on("unlink", (filePath) => {
1604
+ emitEvent("delete", filePath);
1605
+ });
1606
+ return {
1607
+ on(_event, handler) {
1608
+ handlers.push(handler);
1609
+ },
1610
+ async close() {
1611
+ await watcher.close();
1612
+ }
1613
+ };
1614
+ }
1615
+
1616
+ //#endregion
1617
+ //#region src/utils/file-metadata-cache.ts
1618
+ /**
1619
+ * In-memory cache on top of state-persistence.
1620
+ */
1621
+ /** Normalize file name for case-insensitive lookup (macOS/Windows compat) */
1622
+ function normalizeKey(fileName) {
1623
+ return fileName.toLowerCase();
1624
+ }
1625
+ var FileMetadataCache = class {
1626
+ metadata = /* @__PURE__ */ new Map();
1627
+ persisted = /* @__PURE__ */ new Map();
1628
+ projectDir = null;
1629
+ initialized = false;
1630
+ pendingPersist = null;
1631
+ async initialize(projectDir) {
1632
+ if (this.initialized && this.projectDir === projectDir) return;
1633
+ this.projectDir = projectDir;
1634
+ const loaded = await loadPersistedState(projectDir);
1635
+ this.persisted = loaded;
1636
+ this.metadata = /* @__PURE__ */ new Map();
1637
+ for (const [fileName, state] of loaded.entries()) this.metadata.set(normalizeKey(fileName), {
1638
+ localHash: state.contentHash,
1639
+ lastSyncedHash: state.contentHash,
1640
+ lastRemoteTimestamp: state.timestamp
1641
+ });
1642
+ this.initialized = true;
1643
+ }
1644
+ get(fileName) {
1645
+ return this.metadata.get(normalizeKey(fileName));
1646
+ }
1647
+ has(fileName) {
1648
+ return this.metadata.has(normalizeKey(fileName));
1649
+ }
1650
+ size() {
1651
+ return this.metadata.size;
1652
+ }
1653
+ getPersistedState() {
1654
+ return this.persisted;
1655
+ }
1656
+ recordRemoteWrite(fileName, content, remoteModifiedAt) {
1657
+ const key = normalizeKey(fileName);
1658
+ const contentHash = hashFileContent(content);
1659
+ this.metadata.set(key, {
1660
+ localHash: contentHash,
1661
+ lastSyncedHash: contentHash,
1662
+ lastRemoteTimestamp: remoteModifiedAt
1663
+ });
1664
+ this.persisted.set(key, {
1665
+ contentHash,
1666
+ timestamp: remoteModifiedAt
1667
+ });
1668
+ this.schedulePersist();
1669
+ }
1670
+ recordSyncedSnapshot(fileName, contentHash, remoteModifiedAt) {
1671
+ const key = normalizeKey(fileName);
1672
+ this.metadata.set(key, {
1673
+ localHash: contentHash,
1674
+ lastSyncedHash: contentHash,
1675
+ lastRemoteTimestamp: remoteModifiedAt
1676
+ });
1677
+ this.persisted.set(key, {
1678
+ contentHash,
1679
+ timestamp: remoteModifiedAt
1680
+ });
1681
+ this.schedulePersist();
1682
+ }
1683
+ recordDelete(fileName) {
1684
+ const key = normalizeKey(fileName);
1685
+ this.metadata.delete(key);
1686
+ this.persisted.delete(key);
1687
+ this.schedulePersist();
1688
+ }
1689
+ async flush() {
1690
+ if (this.pendingPersist) await this.pendingPersist;
1691
+ }
1692
+ schedulePersist() {
1693
+ const projectDir = this.projectDir;
1694
+ if (!projectDir) return;
1695
+ this.pendingPersist ??= (async () => {
1696
+ try {
1697
+ await Promise.resolve();
1698
+ await savePersistedState(projectDir, this.persisted);
1699
+ } finally {
1700
+ this.pendingPersist = null;
1701
+ }
1702
+ })();
1703
+ }
1704
+ };
1705
+
1706
+ //#endregion
1707
+ //#region src/utils/hash-tracker.ts
1708
+ /**
1709
+ * Hash tracking utilities for echo prevention
1710
+ *
1711
+ * The hash tracker prevents echo loops by remembering content hashes
1712
+ * and skipping watcher events for files we just wrote.
1713
+ */
1714
+ /**
1715
+ * Creates a hash tracker instance for echo prevention
1716
+ */
1717
+ function createHashTracker() {
1718
+ const hashes = /* @__PURE__ */ new Map();
1719
+ const pendingDeletes = /* @__PURE__ */ new Map();
1720
+ return {
1721
+ remember(filePath, content) {
1722
+ const hash = hashContent(content);
1723
+ hashes.set(filePath, hash);
1724
+ },
1725
+ shouldSkip(filePath, content) {
1726
+ const currentHash = hashContent(content);
1727
+ return hashes.get(filePath) === currentHash;
1728
+ },
1729
+ forget(filePath) {
1730
+ hashes.delete(filePath);
1731
+ },
1732
+ clear() {
1733
+ hashes.clear();
1734
+ },
1735
+ markDelete(filePath) {
1736
+ const existingTimer = pendingDeletes.get(filePath);
1737
+ if (existingTimer) clearTimeout(existingTimer);
1738
+ const timeout = setTimeout(() => {
1739
+ pendingDeletes.delete(filePath);
1740
+ }, 5e3);
1741
+ pendingDeletes.set(filePath, timeout);
1742
+ },
1743
+ shouldSkipDelete(filePath) {
1744
+ return pendingDeletes.has(filePath);
1745
+ },
1746
+ clearDelete(filePath) {
1747
+ const timeout = pendingDeletes.get(filePath);
1748
+ if (timeout) clearTimeout(timeout);
1749
+ pendingDeletes.delete(filePath);
1750
+ }
1751
+ };
1752
+ }
1753
+ /**
1754
+ * Computes a SHA256 hash of file content for comparison
1755
+ */
1756
+ function hashContent(content) {
1757
+ return createHash("sha256").update(content).digest("hex");
1758
+ }
1759
+
1747
1760
  //#endregion
1748
1761
  //#region src/utils/project.ts
1749
1762
  function toPackageName(name) {
@@ -1810,12 +1823,6 @@ async function matchesProject(packageJsonPath, projectHash) {
1810
1823
 
1811
1824
  //#endregion
1812
1825
  //#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
1826
  /** Log helper */
1820
1827
  function log(level, message) {
1821
1828
  return {
@@ -1898,9 +1905,9 @@ function transition(state, event) {
1898
1905
  state,
1899
1906
  effects
1900
1907
  };
1901
- case "FILE_LIST":
1908
+ case "REMOTE_FILE_LIST":
1902
1909
  if (state.mode !== "handshaking") {
1903
- effects.push(log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`));
1910
+ effects.push(log("warn", `Received REMOTE_FILE_LIST in mode ${state.mode}, ignoring`));
1904
1911
  return {
1905
1912
  state,
1906
1913
  effects
@@ -1977,7 +1984,7 @@ function transition(state, event) {
1977
1984
  effects
1978
1985
  };
1979
1986
  }
1980
- case "FILE_CHANGE": {
1987
+ case "REMOTE_FILE_CHANGE": {
1981
1988
  const validation = validateIncomingChange(event.fileMeta, state.mode);
1982
1989
  if (validation.action === "queue") {
1983
1990
  effects.push(log("debug", `Queueing file change: ${event.file.name} (${validation.reason})`));
@@ -2478,13 +2485,13 @@ async function start(config) {
2478
2485
  case "file-list":
2479
2486
  debug(`Received file list: ${message.files.length} files`);
2480
2487
  event = {
2481
- type: "FILE_LIST",
2488
+ type: "REMOTE_FILE_LIST",
2482
2489
  files: message.files
2483
2490
  };
2484
2491
  break;
2485
2492
  case "file-change":
2486
2493
  event = {
2487
- type: "FILE_CHANGE",
2494
+ type: "REMOTE_FILE_CHANGE",
2488
2495
  file: {
2489
2496
  name: message.fileName,
2490
2497
  content: message.content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.mjs",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "@typescript/ata": "^0.9.8",
26
26
  "chokidar": "^5.0.0",
27
- "commander": "^14.0.2",
27
+ "commander": "^14.0.3",
28
28
  "prettier": "^3.7.4",
29
29
  "typescript": "^5.9.3",
30
30
  "ws": "^8.18.3"
@@ -33,7 +33,7 @@
33
33
  "@code-link/shared": "1.0.0",
34
34
  "@types/node": "^22.19.2",
35
35
  "@types/ws": "^8.18.1",
36
- "tsdown": "^0.17.4",
36
+ "tsdown": "^0.20.1",
37
37
  "tsx": "^4.21.0",
38
38
  "vitest": "^4.0.15"
39
39
  }