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.
- package/dist/index.mjs +487 -480
- 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
|
|
112
|
-
LogLevel
|
|
113
|
-
LogLevel
|
|
114
|
-
LogLevel
|
|
115
|
-
LogLevel
|
|
116
|
-
return LogLevel
|
|
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
|
|
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
|
|
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
|
|
584
|
+
//#region src/utils/node-paths.ts
|
|
423
585
|
/**
|
|
424
|
-
*
|
|
586
|
+
* Path manipulation utilities
|
|
425
587
|
*/
|
|
426
|
-
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
427
588
|
/**
|
|
428
|
-
*
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
613
|
+
const normalized = stack.join("/");
|
|
614
|
+
if (isAbsolute) return `/${normalized}`;
|
|
615
|
+
return normalized;
|
|
454
616
|
}
|
|
455
617
|
|
|
456
618
|
//#endregion
|
|
457
|
-
//#region
|
|
619
|
+
//#region src/utils/state-persistence.ts
|
|
458
620
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Port range: 3847-4096 (250 possible ports)
|
|
461
|
-
* Must match between CLI and plugin.
|
|
621
|
+
* State persistence helper
|
|
462
622
|
*
|
|
463
|
-
*
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 =
|
|
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))
|
|
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
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
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
|
|
968
|
-
debug(`
|
|
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
|
|
1158
|
-
warn(`ATA warning: ${message}`, error
|
|
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
|
|
1281
|
-
if (!version
|
|
1282
|
-
const pkg = npmData.versions[version
|
|
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
|
|
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
|
|
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
|
|
1416
|
-
const baseUrl = `https://unpkg.com/${pkgName}@${version
|
|
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
|
|
1461
|
-
const isRetryable = error
|
|
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
|
|
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
|
|
1469
|
-
throw error
|
|
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 "
|
|
1908
|
+
case "REMOTE_FILE_LIST":
|
|
1902
1909
|
if (state.mode !== "handshaking") {
|
|
1903
|
-
effects.push(log("warn", `Received
|
|
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 "
|
|
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: "
|
|
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: "
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
+
"tsdown": "^0.20.1",
|
|
37
37
|
"tsx": "^4.21.0",
|
|
38
38
|
"vitest": "^4.0.15"
|
|
39
39
|
}
|