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