framer-code-link 0.17.0 → 0.17.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.mjs +1902 -752
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { Command } from "commander";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
4
|
import fs from "fs/promises";
|
|
5
5
|
import path from "path";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import nodeFs from "node:fs";
|
|
9
|
+
import fs$1 from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path$1 from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import https from "node:https";
|
|
6
14
|
import { WebSocketServer } from "ws";
|
|
7
|
-
import { createHash } from "crypto";
|
|
15
|
+
import { createHash as createHash$1 } from "crypto";
|
|
8
16
|
import { execSync } from "child_process";
|
|
9
|
-
import fs$
|
|
17
|
+
import fs$2 from "fs";
|
|
10
18
|
import { setupTypeAcquisition } from "@typescript/ata";
|
|
11
19
|
import ts from "typescript";
|
|
12
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -142,7 +150,7 @@ function pathJoin(...parts) {
|
|
|
142
150
|
});
|
|
143
151
|
return res;
|
|
144
152
|
}
|
|
145
|
-
function normalizePath
|
|
153
|
+
function normalizePath(filePath) {
|
|
146
154
|
if (!filePath) return "";
|
|
147
155
|
const isAbsolute = filePath.startsWith("/");
|
|
148
156
|
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
@@ -159,12 +167,24 @@ function normalizePath$1(filePath) {
|
|
|
159
167
|
if (isAbsolute) return `/${normalized}`;
|
|
160
168
|
return normalized;
|
|
161
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Use when you only want path normalization.
|
|
172
|
+
* Preserves the caller-provided extension so `Foo.ts` and `Foo.tsx` stay distinct.
|
|
173
|
+
*/
|
|
162
174
|
function normalizeCodeFilePath(filePath) {
|
|
163
|
-
const normalized = normalizePath
|
|
175
|
+
const normalized = normalizePath(filePath);
|
|
164
176
|
return normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
165
177
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
178
|
+
function ensureExtension(filePath, extension = ".tsx") {
|
|
179
|
+
const normalized = normalizeCodeFilePath(filePath);
|
|
180
|
+
return /\.(tsx?|jsx?|json)$/i.test(normalized) ? normalized : `${normalized}${extension}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Use when the path must match the code-file API contract.
|
|
184
|
+
* Normalizes the path and ensures a default `.tsx` extension when one is missing.
|
|
185
|
+
*/
|
|
186
|
+
function normalizeCodeFilePathWithExtension(filePath) {
|
|
187
|
+
return ensureExtension(filePath);
|
|
168
188
|
}
|
|
169
189
|
function sanitizeFilePath(input, capitalizeReactComponent = true) {
|
|
170
190
|
const trimmed = input.trim();
|
|
@@ -188,7 +208,7 @@ function isSupportedExtension$1(filePath) {
|
|
|
188
208
|
* Use this for Map keys on operating systems where "File.tsx" and "file.tsx" are the same file.
|
|
189
209
|
*/
|
|
190
210
|
function fileKeyForLookup(filePath) {
|
|
191
|
-
return
|
|
211
|
+
return normalizeCodeFilePath(filePath).toLowerCase();
|
|
192
212
|
}
|
|
193
213
|
/**
|
|
194
214
|
* Pluralize a word based on count
|
|
@@ -220,6 +240,11 @@ function getPortFromHash(projectHash) {
|
|
|
220
240
|
return 3847 + Math.abs(hash) % 250;
|
|
221
241
|
}
|
|
222
242
|
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region ../code-link-shared/src/types.ts
|
|
245
|
+
/** Custom close code sent when a new plugin tab replaces the active one. */
|
|
246
|
+
const CLOSE_CODE_REPLACED = 4001;
|
|
247
|
+
|
|
223
248
|
//#endregion
|
|
224
249
|
//#region ../../node_modules/picocolors/picocolors.js
|
|
225
250
|
var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
@@ -292,14 +317,22 @@ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
292
317
|
//#endregion
|
|
293
318
|
//#region src/utils/logging.ts
|
|
294
319
|
var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
const LogLevel = {
|
|
321
|
+
DEBUG: "debug",
|
|
322
|
+
INFO: "info",
|
|
323
|
+
WARN: "warn",
|
|
324
|
+
ERROR: "error"
|
|
325
|
+
};
|
|
326
|
+
const LOG_PRIORITY = {
|
|
327
|
+
[LogLevel.DEBUG]: 0,
|
|
328
|
+
[LogLevel.INFO]: 1,
|
|
329
|
+
[LogLevel.WARN]: 2,
|
|
330
|
+
[LogLevel.ERROR]: 3
|
|
331
|
+
};
|
|
302
332
|
let currentLevel = LogLevel.INFO;
|
|
333
|
+
function allows(level) {
|
|
334
|
+
return LOG_PRIORITY[currentLevel] <= LOG_PRIORITY[level];
|
|
335
|
+
}
|
|
303
336
|
let lastMessage = "";
|
|
304
337
|
let lastMessageCount = 0;
|
|
305
338
|
const CLEAR_LINE = "\x1B[2K";
|
|
@@ -308,10 +341,6 @@ function rewriteLastLine(text) {
|
|
|
308
341
|
if (process.stdout.isTTY) process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`);
|
|
309
342
|
else process.stdout.write(`${text}\n`);
|
|
310
343
|
}
|
|
311
|
-
let disconnectTimer = null;
|
|
312
|
-
let isShowingDisconnect = false;
|
|
313
|
-
let hadRecentDisconnect = false;
|
|
314
|
-
const DISCONNECT_DELAY_MS = 4e3;
|
|
315
344
|
function setLogLevel(level) {
|
|
316
345
|
currentLevel = level;
|
|
317
346
|
}
|
|
@@ -345,7 +374,7 @@ function logWithDedupe(message, writer) {
|
|
|
345
374
|
function banner(version, port) {
|
|
346
375
|
console.log();
|
|
347
376
|
let message = ` ${import_picocolors.default.cyan(import_picocolors.default.bold("⚡ Code Link"))} ${import_picocolors.default.dim(`v${version}`)}`;
|
|
348
|
-
if (
|
|
377
|
+
if (allows(LogLevel.DEBUG)) message += ` ${import_picocolors.default.dim("Port")} ${import_picocolors.default.yellow(port)}`;
|
|
349
378
|
console.log(message);
|
|
350
379
|
console.log();
|
|
351
380
|
}
|
|
@@ -353,13 +382,13 @@ function banner(version, port) {
|
|
|
353
382
|
* Debug-level logging - only shown with --verbose flag
|
|
354
383
|
*/
|
|
355
384
|
function debug(message, ...args) {
|
|
356
|
-
if (
|
|
385
|
+
if (allows(LogLevel.DEBUG)) console.debug(import_picocolors.default.dim(`[DEBUG] ${message}`), ...args);
|
|
357
386
|
}
|
|
358
387
|
/**
|
|
359
388
|
* Info-level logging - shown by default, no prefix
|
|
360
389
|
*/
|
|
361
390
|
function info(message, ...args) {
|
|
362
|
-
if (
|
|
391
|
+
if (allows(LogLevel.INFO)) {
|
|
363
392
|
const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message;
|
|
364
393
|
logWithDedupe(formatted, () => {
|
|
365
394
|
console.log(formatted);
|
|
@@ -370,7 +399,7 @@ function info(message, ...args) {
|
|
|
370
399
|
* Warning-level logging
|
|
371
400
|
*/
|
|
372
401
|
function warn(message, ...args) {
|
|
373
|
-
if (
|
|
402
|
+
if (allows(LogLevel.WARN)) {
|
|
374
403
|
if (message === lastMessage) return;
|
|
375
404
|
flushDedupe();
|
|
376
405
|
lastMessage = message;
|
|
@@ -382,7 +411,7 @@ function warn(message, ...args) {
|
|
|
382
411
|
* Error-level logging
|
|
383
412
|
*/
|
|
384
413
|
function error(message, ...args) {
|
|
385
|
-
if (
|
|
414
|
+
if (allows(LogLevel.ERROR)) {
|
|
386
415
|
flushDedupe();
|
|
387
416
|
console.error(import_picocolors.default.red(`✗ ${message}`), ...args);
|
|
388
417
|
}
|
|
@@ -391,7 +420,7 @@ function error(message, ...args) {
|
|
|
391
420
|
* Success message with checkmark
|
|
392
421
|
*/
|
|
393
422
|
function success(message, ...args) {
|
|
394
|
-
if (
|
|
423
|
+
if (allows(LogLevel.INFO)) {
|
|
395
424
|
flushDedupe();
|
|
396
425
|
console.log(import_picocolors.default.green(`✓ ${message}`), ...args);
|
|
397
426
|
}
|
|
@@ -400,7 +429,7 @@ function success(message, ...args) {
|
|
|
400
429
|
* File sync indicators
|
|
401
430
|
*/
|
|
402
431
|
function fileDown(fileName) {
|
|
403
|
-
if (
|
|
432
|
+
if (allows(LogLevel.INFO)) {
|
|
404
433
|
const msg = ` ${import_picocolors.default.blue("↓")} ${fileName}`;
|
|
405
434
|
logWithDedupe(msg, () => {
|
|
406
435
|
console.log(msg);
|
|
@@ -408,7 +437,7 @@ function fileDown(fileName) {
|
|
|
408
437
|
}
|
|
409
438
|
}
|
|
410
439
|
function fileUp(fileName) {
|
|
411
|
-
if (
|
|
440
|
+
if (allows(LogLevel.INFO)) {
|
|
412
441
|
const msg = ` ${import_picocolors.default.green("↑")} ${fileName}`;
|
|
413
442
|
logWithDedupe(msg, () => {
|
|
414
443
|
console.log(msg);
|
|
@@ -416,7 +445,7 @@ function fileUp(fileName) {
|
|
|
416
445
|
}
|
|
417
446
|
}
|
|
418
447
|
function fileDelete(fileName) {
|
|
419
|
-
if (
|
|
448
|
+
if (allows(LogLevel.INFO)) {
|
|
420
449
|
const msg = ` ${import_picocolors.default.red("×")} ${fileName}`;
|
|
421
450
|
logWithDedupe(msg, () => {
|
|
422
451
|
console.log(msg);
|
|
@@ -427,76 +456,256 @@ function fileDelete(fileName) {
|
|
|
427
456
|
* Status message (dimmed, for "watching for changes..." etc)
|
|
428
457
|
*/
|
|
429
458
|
function status(message) {
|
|
430
|
-
if (
|
|
459
|
+
if (allows(LogLevel.INFO)) {
|
|
431
460
|
flushDedupe();
|
|
432
461
|
console.log(import_picocolors.default.dim(` ${message}`));
|
|
433
462
|
}
|
|
434
463
|
}
|
|
464
|
+
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/helpers/certs.ts
|
|
435
467
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
468
|
+
* Certificate management for WSS support.
|
|
469
|
+
*
|
|
470
|
+
* Downloads FiloSottile's mkcert binary on first run, then shells out to it
|
|
471
|
+
* to generate and trust a local CA + server certificate for wss://localhost.
|
|
472
|
+
*
|
|
473
|
+
* The mkcert binary is SHA-256 verified before execution (update
|
|
474
|
+
* MKCERT_CHECKSUMS when bumping MKCERT_VERSION). The CA key is user-only;
|
|
475
|
+
* never share or commit the cert directory.
|
|
476
|
+
*
|
|
477
|
+
* Certs and the mkcert binary are cached in ~/.framer/code-link/.
|
|
438
478
|
*/
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
479
|
+
const execFileAsync = promisify(execFile);
|
|
480
|
+
/** Keep in sync with MKCERT_CHECKSUMS below. */
|
|
481
|
+
const MKCERT_VERSION = "v1.4.4";
|
|
482
|
+
const CERT_DIR = process.env.FRAMER_CODE_LINK_CERT_DIR ?? path$1.join(os.homedir(), ".framer", "code-link");
|
|
483
|
+
const MKCERT_BIN_NAME = process.platform === "win32" ? "mkcert.exe" : "mkcert";
|
|
484
|
+
const MKCERT_BIN_PATH = path$1.join(CERT_DIR, MKCERT_BIN_NAME);
|
|
485
|
+
const ROOT_CA_CERT_PATH = path$1.join(CERT_DIR, "rootCA.pem");
|
|
486
|
+
const ROOT_CA_KEY_PATH = path$1.join(CERT_DIR, "rootCA-key.pem");
|
|
487
|
+
const SERVER_KEY_PATH = path$1.join(CERT_DIR, "localhost-key.pem");
|
|
488
|
+
const SERVER_CERT_PATH = path$1.join(CERT_DIR, "localhost.pem");
|
|
449
489
|
/**
|
|
450
|
-
*
|
|
490
|
+
* SHA-256 checksums for mkcert v1.4.4 release binaries, keyed by "platform-arch".
|
|
491
|
+
* These must be updated whenever MKCERT_VERSION changes.
|
|
492
|
+
* Source: https://github.com/FiloSottile/mkcert/releases/tag/v1.4.4
|
|
451
493
|
*/
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
494
|
+
const MKCERT_CHECKSUMS = {
|
|
495
|
+
"darwin-amd64": "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e",
|
|
496
|
+
"darwin-arm64": "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6",
|
|
497
|
+
"linux-amd64": "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52",
|
|
498
|
+
"linux-arm64": "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0",
|
|
499
|
+
"windows-amd64": "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398",
|
|
500
|
+
"windows-arm64": "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e"
|
|
501
|
+
};
|
|
502
|
+
/** Env vars passed to every mkcert invocation. */
|
|
503
|
+
const MKCERT_ENV = {
|
|
504
|
+
...process.env,
|
|
505
|
+
CAROOT: CERT_DIR,
|
|
506
|
+
JAVA_HOME: "",
|
|
507
|
+
...process.platform === "darwin" ? { TRUST_STORES: "system" } : {}
|
|
508
|
+
};
|
|
458
509
|
/**
|
|
459
|
-
*
|
|
510
|
+
* Returns a TLS cert bundle for the WSS server, or null if generation fails.
|
|
511
|
+
* On first run, downloads mkcert, installs a local CA into trust stores, and
|
|
512
|
+
* generates a server cert for localhost.
|
|
460
513
|
*/
|
|
461
|
-
function
|
|
462
|
-
|
|
514
|
+
async function getOrCreateCerts() {
|
|
515
|
+
try {
|
|
516
|
+
await fs$1.mkdir(CERT_DIR, { recursive: true });
|
|
517
|
+
const mkcertPath = await ensureMkcertBinary();
|
|
518
|
+
const rootCAState = await syncRootCA(mkcertPath);
|
|
519
|
+
if (rootCAState !== "unchanged") await invalidateServerCerts(rootCAState);
|
|
520
|
+
const existingKey = await loadFile(SERVER_KEY_PATH);
|
|
521
|
+
const existingCert = await loadFile(SERVER_CERT_PATH);
|
|
522
|
+
if (existingKey && existingCert) {
|
|
523
|
+
debug("Loaded existing server certificates from disk");
|
|
524
|
+
return {
|
|
525
|
+
key: existingKey,
|
|
526
|
+
cert: existingCert
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (existingKey || existingCert) await invalidateIncompleteServerBundle();
|
|
530
|
+
status("Generating local certificates to connect securely. You may be asked for your password.");
|
|
531
|
+
await generateCerts(mkcertPath);
|
|
532
|
+
status("Successfully generated certificates.");
|
|
533
|
+
return {
|
|
534
|
+
key: await fs$1.readFile(SERVER_KEY_PATH, "utf-8"),
|
|
535
|
+
cert: await fs$1.readFile(SERVER_CERT_PATH, "utf-8")
|
|
536
|
+
};
|
|
537
|
+
} catch (err) {
|
|
538
|
+
error(`Failed to set up TLS certificates: ${err instanceof Error ? err.message : String(err)}`);
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
463
541
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
542
|
+
function getDownloadInfo() {
|
|
543
|
+
const platformMap = {
|
|
544
|
+
darwin: "darwin",
|
|
545
|
+
linux: "linux",
|
|
546
|
+
win32: "windows"
|
|
547
|
+
};
|
|
548
|
+
const archMap = {
|
|
549
|
+
x64: "amd64",
|
|
550
|
+
arm64: "arm64"
|
|
551
|
+
};
|
|
552
|
+
const platform = platformMap[process.platform];
|
|
553
|
+
const arch = archMap[process.arch];
|
|
554
|
+
if (!platform || !arch) throw new Error(`Unsupported platform: ${process.platform}/${process.arch}. Install mkcert manually: https://github.com/FiloSottile/mkcert#installation`);
|
|
555
|
+
const key = `${platform}-${arch}`;
|
|
556
|
+
const expectedChecksum = MKCERT_CHECKSUMS[key];
|
|
557
|
+
if (!expectedChecksum) throw new Error(`No checksum available for mkcert ${key}. Install mkcert manually: https://github.com/FiloSottile/mkcert#installation`);
|
|
558
|
+
return {
|
|
559
|
+
url: `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/${`mkcert-${MKCERT_VERSION}-${platform}-${arch}${process.platform === "win32" ? ".exe" : ""}`}`,
|
|
560
|
+
expectedChecksum
|
|
561
|
+
};
|
|
469
562
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
563
|
+
async function ensureMkcertBinary() {
|
|
564
|
+
const { url, expectedChecksum } = getDownloadInfo();
|
|
565
|
+
try {
|
|
566
|
+
await fs$1.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
567
|
+
if (await verifyFileChecksum(MKCERT_BIN_PATH, expectedChecksum)) {
|
|
568
|
+
debug("mkcert binary already available and verified");
|
|
569
|
+
return MKCERT_BIN_PATH;
|
|
570
|
+
}
|
|
571
|
+
warn("Cached mkcert binary failed checksum verification, re-downloading...");
|
|
572
|
+
} catch {}
|
|
573
|
+
debug(`Downloading mkcert from ${url}`);
|
|
574
|
+
status("Downloading mkcert for certificate generation...");
|
|
575
|
+
try {
|
|
576
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
577
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
578
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
579
|
+
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
580
|
+
if (actualChecksum !== expectedChecksum) throw new Error(`mkcert binary checksum mismatch — the download may have been tampered with.\n Expected: ${expectedChecksum}\n Actual: ${actualChecksum}`);
|
|
581
|
+
await fs$1.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
582
|
+
debug(`mkcert binary saved to ${MKCERT_BIN_PATH}`);
|
|
583
|
+
return MKCERT_BIN_PATH;
|
|
584
|
+
} catch (err) {
|
|
585
|
+
await fs$1.rm(MKCERT_BIN_PATH, { force: true });
|
|
586
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
587
|
+
throw new Error(`Failed to download mkcert: ${message}\nYou can install it manually: https://github.com/FiloSottile/mkcert#installation\nThen run: mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async function generateCerts(mkcertPath) {
|
|
591
|
+
debug("Running mkcert to install the local root CA...");
|
|
592
|
+
try {
|
|
593
|
+
await execFileAsync(mkcertPath, ["-install"], { env: MKCERT_ENV });
|
|
594
|
+
} catch (err) {
|
|
595
|
+
throw new Error(`Failed to install mkcert root CA into the system trust store. If you canceled the password prompt, rerun this command and allow the install.
|
|
596
|
+
mkcert error: ${formatMkcertError(err)}`);
|
|
597
|
+
}
|
|
598
|
+
debug("Running mkcert to generate the localhost server certificate...");
|
|
599
|
+
try {
|
|
600
|
+
await execFileAsync(mkcertPath, [
|
|
601
|
+
"-key-file",
|
|
602
|
+
SERVER_KEY_PATH,
|
|
603
|
+
"-cert-file",
|
|
604
|
+
SERVER_CERT_PATH,
|
|
605
|
+
"localhost",
|
|
606
|
+
"127.0.0.1"
|
|
607
|
+
], { env: MKCERT_ENV });
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (await loadFile(SERVER_KEY_PATH) || await loadFile(SERVER_CERT_PATH)) await invalidateIncompleteServerBundle();
|
|
610
|
+
throw new Error(`Failed to generate localhost TLS certificate and key with mkcert.
|
|
611
|
+
mkcert error: ${formatMkcertError(err)}\nPlease rerun:\n mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
612
|
+
}
|
|
613
|
+
const [generatedKey, generatedCert] = await Promise.all([loadFile(SERVER_KEY_PATH), loadFile(SERVER_CERT_PATH)]);
|
|
614
|
+
if (generatedKey && generatedCert) {
|
|
615
|
+
debug("CA installed and server certificate generated successfully");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (generatedKey || generatedCert) await invalidateIncompleteServerBundle();
|
|
619
|
+
throw new Error(`Failed to generate localhost TLS certificate and key with mkcert. Please ensure mkcert is installed and rerun:
|
|
620
|
+
mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1`);
|
|
621
|
+
}
|
|
622
|
+
async function syncRootCA(mkcertPath) {
|
|
623
|
+
const existingRootCert = await loadFile(ROOT_CA_CERT_PATH);
|
|
624
|
+
const existingRootKey = await loadFile(ROOT_CA_KEY_PATH);
|
|
625
|
+
const { stdout } = await execFileAsync(mkcertPath, ["-CAROOT"], { env: {
|
|
626
|
+
...process.env,
|
|
627
|
+
JAVA_HOME: ""
|
|
628
|
+
} });
|
|
629
|
+
const defaultCAROOT = stdout.trim();
|
|
630
|
+
if (!defaultCAROOT || defaultCAROOT === CERT_DIR) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
631
|
+
const defaultRootCert = await loadFile(path$1.join(defaultCAROOT, "rootCA.pem"));
|
|
632
|
+
const defaultRootKey = await loadFile(path$1.join(defaultCAROOT, "rootCA-key.pem"));
|
|
633
|
+
if (!defaultRootCert || !defaultRootKey) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
634
|
+
if (existingRootCert === defaultRootCert && existingRootKey === defaultRootKey) return "unchanged";
|
|
635
|
+
await Promise.all([fs$1.rm(ROOT_CA_CERT_PATH, { force: true }), fs$1.rm(ROOT_CA_KEY_PATH, { force: true })]);
|
|
636
|
+
await fs$1.writeFile(ROOT_CA_CERT_PATH, defaultRootCert, { mode: 420 });
|
|
637
|
+
await fs$1.writeFile(ROOT_CA_KEY_PATH, defaultRootKey, { mode: 384 });
|
|
638
|
+
return existingRootCert && existingRootKey ? "updated" : "copied";
|
|
639
|
+
}
|
|
640
|
+
async function invalidateServerCerts(rootCAState) {
|
|
641
|
+
const reasons = {
|
|
642
|
+
copied: "Copied an existing mkcert root CA into the Code Link cache",
|
|
643
|
+
updated: "Detected a different mkcert root CA and refreshed the Code Link cache",
|
|
644
|
+
missing: "No cached mkcert root CA was available for the existing server certificate"
|
|
645
|
+
};
|
|
646
|
+
if (!(await loadFile(SERVER_KEY_PATH) !== null || await loadFile(SERVER_CERT_PATH) !== null)) return;
|
|
647
|
+
await fs$1.rm(SERVER_KEY_PATH, { force: true });
|
|
648
|
+
await fs$1.rm(SERVER_CERT_PATH, { force: true });
|
|
649
|
+
debug(`${reasons[rootCAState]}; removed stale localhost certificate`);
|
|
650
|
+
}
|
|
651
|
+
async function invalidateIncompleteServerBundle() {
|
|
652
|
+
await fs$1.rm(SERVER_KEY_PATH, { force: true });
|
|
653
|
+
await fs$1.rm(SERVER_CERT_PATH, { force: true });
|
|
654
|
+
warn("Found an incomplete localhost certificate bundle; regenerating it");
|
|
655
|
+
}
|
|
656
|
+
async function verifyFileChecksum(filePath, expectedHash) {
|
|
657
|
+
const data = await fs$1.readFile(filePath);
|
|
658
|
+
return createHash("sha256").update(data).digest("hex") === expectedHash;
|
|
659
|
+
}
|
|
660
|
+
async function loadFile(filePath) {
|
|
661
|
+
try {
|
|
662
|
+
return await fs$1.readFile(filePath, "utf-8");
|
|
663
|
+
} catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function formatMkcertError(err) {
|
|
668
|
+
if (err instanceof Error) {
|
|
669
|
+
const stdout = "stdout" in err && typeof err.stdout === "string" ? err.stdout.trim() : "";
|
|
670
|
+
const output = ["stderr" in err && typeof err.stderr === "string" ? err.stderr.trim() : "", stdout].filter(Boolean).join("\n");
|
|
671
|
+
return output ? `${err.message}\n${output}` : err.message;
|
|
672
|
+
}
|
|
673
|
+
return String(err);
|
|
476
674
|
}
|
|
477
675
|
|
|
478
676
|
//#endregion
|
|
479
677
|
//#region src/helpers/connection.ts
|
|
480
678
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
679
|
+
* WebSocket connection helper
|
|
680
|
+
*
|
|
681
|
+
* Wrapper around ws.Server that normalizes handshake and surfaces callbacks.
|
|
682
|
+
*/
|
|
683
|
+
/**
|
|
684
|
+
* Initializes a WSS (TLS) WebSocket server and returns a connection interface.
|
|
685
|
+
* Returns a Promise that resolves when the server is ready, or rejects on startup errors.
|
|
483
686
|
*/
|
|
484
|
-
function initConnection(port) {
|
|
687
|
+
function initConnection(port, certs) {
|
|
485
688
|
return new Promise((resolve, reject) => {
|
|
486
|
-
const wss = new WebSocketServer({ port });
|
|
487
689
|
const handlers = {};
|
|
488
690
|
let connectionId = 0;
|
|
489
691
|
let isReady = false;
|
|
692
|
+
const httpsServer = https.createServer({
|
|
693
|
+
key: certs.key,
|
|
694
|
+
cert: certs.cert
|
|
695
|
+
});
|
|
696
|
+
const wss = new WebSocketServer({ server: httpsServer });
|
|
490
697
|
wss.on("error", (err) => {
|
|
698
|
+
error(`WebSocket server error: ${err.message}`);
|
|
699
|
+
handlers.onError?.(err);
|
|
700
|
+
});
|
|
701
|
+
const handleError = (err) => {
|
|
491
702
|
if (!isReady) {
|
|
492
703
|
if (err.code === "EADDRINUSE") {
|
|
493
704
|
error(`Port ${port} is already in use.`);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
|
|
499
|
-
error(` Then kill the process: kill -9 <PID>`);
|
|
705
|
+
info(`This usually means another instance of Code Link is already running.`);
|
|
706
|
+
info(``);
|
|
707
|
+
info(`To fix this:`);
|
|
708
|
+
info(` Close any other terminal running Code Link for this project`);
|
|
500
709
|
reject(/* @__PURE__ */ new Error(`Port ${port} is already in use`));
|
|
501
710
|
} else {
|
|
502
711
|
error(`Failed to start WebSocket server: ${err.message}`);
|
|
@@ -505,10 +714,11 @@ function initConnection(port) {
|
|
|
505
714
|
return;
|
|
506
715
|
}
|
|
507
716
|
error(`WebSocket server error: ${err.message}`);
|
|
508
|
-
|
|
509
|
-
|
|
717
|
+
handlers.onError?.(err);
|
|
718
|
+
};
|
|
719
|
+
const handleListening = () => {
|
|
510
720
|
isReady = true;
|
|
511
|
-
debug(`
|
|
721
|
+
debug(`WSS server listening on port ${port}`);
|
|
512
722
|
let activeClient = null;
|
|
513
723
|
wss.on("connection", (ws) => {
|
|
514
724
|
const connId = ++connectionId;
|
|
@@ -524,7 +734,7 @@ function initConnection(port) {
|
|
|
524
734
|
activeClient = ws;
|
|
525
735
|
if (previousActiveClient && previousActiveClient !== activeClient) {
|
|
526
736
|
debug(`Replacing active client with conn ${connId}`);
|
|
527
|
-
if (previousActiveClient.readyState === READY_STATE.OPEN || previousActiveClient.readyState === READY_STATE.CONNECTING) previousActiveClient.close();
|
|
737
|
+
if (previousActiveClient.readyState === READY_STATE.OPEN || previousActiveClient.readyState === READY_STATE.CONNECTING) previousActiveClient.close(CLOSE_CODE_REPLACED);
|
|
528
738
|
}
|
|
529
739
|
handlers.onHandshake?.(ws, message);
|
|
530
740
|
} else if (handshakeReceived && activeClient === ws) handlers.onMessage?.(message);
|
|
@@ -563,10 +773,15 @@ function initConnection(port) {
|
|
|
563
773
|
}
|
|
564
774
|
},
|
|
565
775
|
close() {
|
|
776
|
+
for (const client of wss.clients) client.close(1001);
|
|
566
777
|
wss.close();
|
|
778
|
+
httpsServer.close();
|
|
567
779
|
}
|
|
568
780
|
});
|
|
569
|
-
}
|
|
781
|
+
};
|
|
782
|
+
httpsServer.on("error", handleError);
|
|
783
|
+
httpsServer.on("listening", handleListening);
|
|
784
|
+
httpsServer.listen(port);
|
|
570
785
|
});
|
|
571
786
|
}
|
|
572
787
|
/**
|
|
@@ -608,41 +823,6 @@ function sendMessage(socket, message) {
|
|
|
608
823
|
});
|
|
609
824
|
}
|
|
610
825
|
|
|
611
|
-
//#endregion
|
|
612
|
-
//#region src/utils/node-paths.ts
|
|
613
|
-
/**
|
|
614
|
-
* Path manipulation utilities
|
|
615
|
-
*/
|
|
616
|
-
/**
|
|
617
|
-
* Gets a relative path from the project directory
|
|
618
|
-
*/
|
|
619
|
-
function getRelativePath(projectDir, absolutePath) {
|
|
620
|
-
return path.relative(projectDir, absolutePath);
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Normalizes a file path by:
|
|
624
|
-
* - Converting backslashes to forward slashes
|
|
625
|
-
* - Resolving . and .. segments
|
|
626
|
-
* - Removing duplicate slashes
|
|
627
|
-
*/
|
|
628
|
-
function normalizePath(filePath) {
|
|
629
|
-
if (!filePath) return "";
|
|
630
|
-
const isAbsolute = filePath.startsWith("/");
|
|
631
|
-
const segments = filePath.replace(/\\/g, "/").split("/");
|
|
632
|
-
const stack = [];
|
|
633
|
-
for (const segment of segments) {
|
|
634
|
-
if (!segment || segment === ".") continue;
|
|
635
|
-
if (segment === "..") {
|
|
636
|
-
if (stack.length > 0) stack.pop();
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
stack.push(segment);
|
|
640
|
-
}
|
|
641
|
-
const normalized = stack.join("/");
|
|
642
|
-
if (isAbsolute) return `/${normalized}`;
|
|
643
|
-
return normalized;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
826
|
//#endregion
|
|
647
827
|
//#region src/utils/state-persistence.ts
|
|
648
828
|
/**
|
|
@@ -654,24 +834,14 @@ function normalizePath(filePath) {
|
|
|
654
834
|
*/
|
|
655
835
|
const STATE_FILE_NAME = ".framer-sync-state.json";
|
|
656
836
|
const CURRENT_VERSION = 1;
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
".tsx",
|
|
660
|
-
".js",
|
|
661
|
-
".jsx",
|
|
662
|
-
".json"
|
|
663
|
-
];
|
|
664
|
-
const DEFAULT_EXTENSION$1 = ".tsx";
|
|
665
|
-
function normalizePersistedFileName(fileName) {
|
|
666
|
-
let normalized = normalizePath(fileName.trim());
|
|
667
|
-
if (!SUPPORTED_EXTENSIONS$1.some((ext) => normalized.toLowerCase().endsWith(ext))) normalized = `${normalized}${DEFAULT_EXTENSION$1}`;
|
|
668
|
-
return normalized;
|
|
837
|
+
function persistedFileKey(fileName) {
|
|
838
|
+
return fileKeyForLookup(normalizeCodeFilePathWithExtension(fileName));
|
|
669
839
|
}
|
|
670
840
|
/**
|
|
671
841
|
* Hash file content to detect changes
|
|
672
842
|
*/
|
|
673
843
|
function hashFileContent(content) {
|
|
674
|
-
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
844
|
+
return createHash$1("sha256").update(content, "utf-8").digest("hex");
|
|
675
845
|
}
|
|
676
846
|
/**
|
|
677
847
|
* Load persisted state from disk
|
|
@@ -687,7 +857,7 @@ async function loadPersistedState(projectDir) {
|
|
|
687
857
|
return result;
|
|
688
858
|
}
|
|
689
859
|
for (const [fileName, state] of Object.entries(parsed.files)) {
|
|
690
|
-
const normalizedName =
|
|
860
|
+
const normalizedName = persistedFileKey(fileName);
|
|
691
861
|
if (normalizedName !== fileName) debug(`Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`);
|
|
692
862
|
result.set(normalizedName, state);
|
|
693
863
|
}
|
|
@@ -755,7 +925,7 @@ async function listFiles(filesDir) {
|
|
|
755
925
|
continue;
|
|
756
926
|
}
|
|
757
927
|
if (!isSupportedExtension(entry.name)) continue;
|
|
758
|
-
const sanitizedPath = sanitizeFilePath(normalizePath
|
|
928
|
+
const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
|
|
759
929
|
try {
|
|
760
930
|
const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
|
|
761
931
|
files.push({
|
|
@@ -933,42 +1103,67 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
|
|
|
933
1103
|
remainingConflicts
|
|
934
1104
|
};
|
|
935
1105
|
}
|
|
936
|
-
|
|
937
|
-
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
938
|
-
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
939
|
-
*/
|
|
940
|
-
async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
|
|
1106
|
+
async function writeRemoteFiles(files, filesDir, memory) {
|
|
941
1107
|
debug(`Writing ${pluralize(files.length, "remote file")}`);
|
|
942
|
-
|
|
1108
|
+
const results = [];
|
|
1109
|
+
for (const file of files) {
|
|
943
1110
|
const normalized = resolveRemoteReference(filesDir, file.name);
|
|
944
1111
|
const fullPath = normalized.absolutePath;
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1112
|
+
const prepared = memory.armContentEcho(normalized.relativePath, file.content);
|
|
1113
|
+
try {
|
|
1114
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
1115
|
+
await fs.writeFile(fullPath, file.content, "utf-8");
|
|
1116
|
+
debug(`Wrote file: ${normalized.relativePath}`);
|
|
1117
|
+
results.push({
|
|
1118
|
+
file: {
|
|
1119
|
+
...file,
|
|
1120
|
+
name: normalized.relativePath
|
|
1121
|
+
},
|
|
1122
|
+
path: normalized.relativePath,
|
|
1123
|
+
ok: true
|
|
1124
|
+
});
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
memory.rollbackWriteFailure(prepared);
|
|
1127
|
+
warn(`Failed to write file ${file.name}:`, err);
|
|
1128
|
+
results.push({
|
|
1129
|
+
file: {
|
|
1130
|
+
...file,
|
|
1131
|
+
name: normalized.relativePath
|
|
1132
|
+
},
|
|
1133
|
+
path: normalized.relativePath,
|
|
1134
|
+
ok: false
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
952
1137
|
}
|
|
1138
|
+
return results;
|
|
953
1139
|
}
|
|
954
|
-
|
|
955
|
-
* Deletes a local file from disk
|
|
956
|
-
*/
|
|
957
|
-
async function deleteLocalFile(fileName, filesDir, hashTracker) {
|
|
1140
|
+
async function deleteLocalFile(fileName, filesDir, memory) {
|
|
958
1141
|
const normalized = resolveRemoteReference(filesDir, fileName);
|
|
1142
|
+
const prepared = memory.armExpectedDeleteEcho(normalized.relativePath);
|
|
959
1143
|
try {
|
|
960
|
-
hashTracker.markDelete(normalized.relativePath);
|
|
961
1144
|
await fs.unlink(normalized.absolutePath);
|
|
962
|
-
hashTracker.forget(normalized.relativePath);
|
|
963
1145
|
debug(`Deleted file: ${normalized.relativePath}`);
|
|
1146
|
+
return {
|
|
1147
|
+
fileName: normalized.relativePath,
|
|
1148
|
+
ok: true,
|
|
1149
|
+
alreadyMissing: false
|
|
1150
|
+
};
|
|
964
1151
|
} catch (err) {
|
|
965
1152
|
if (err.code === "ENOENT") {
|
|
966
|
-
hashTracker.forget(normalized.relativePath);
|
|
967
1153
|
debug(`File already deleted: ${normalized.relativePath}`);
|
|
968
|
-
return
|
|
1154
|
+
return {
|
|
1155
|
+
fileName: normalized.relativePath,
|
|
1156
|
+
ok: true,
|
|
1157
|
+
alreadyMissing: true
|
|
1158
|
+
};
|
|
969
1159
|
}
|
|
970
|
-
|
|
1160
|
+
memory.rollbackExpectedDeleteEcho(prepared);
|
|
971
1161
|
warn(`Failed to delete file ${fileName}:`, err);
|
|
1162
|
+
return {
|
|
1163
|
+
fileName: normalized.relativePath,
|
|
1164
|
+
ok: false,
|
|
1165
|
+
alreadyMissing: false
|
|
1166
|
+
};
|
|
972
1167
|
}
|
|
973
1168
|
}
|
|
974
1169
|
/**
|
|
@@ -986,9 +1181,9 @@ async function readFileSafe(fileName, filesDir) {
|
|
|
986
1181
|
* Filter out files whose content matches the last remembered hash.
|
|
987
1182
|
* Used to skip inbound echoes of our own local sends.
|
|
988
1183
|
*/
|
|
989
|
-
function filterEchoedFiles(files,
|
|
1184
|
+
function filterEchoedFiles(files, memory) {
|
|
990
1185
|
return files.filter((file) => {
|
|
991
|
-
return !
|
|
1186
|
+
return !memory.matchesContentEcho(file.name, file.content);
|
|
992
1187
|
});
|
|
993
1188
|
}
|
|
994
1189
|
function resolveRemoteReference(filesDir, rawName) {
|
|
@@ -1000,9 +1195,9 @@ function resolveRemoteReference(filesDir, rawName) {
|
|
|
1000
1195
|
};
|
|
1001
1196
|
}
|
|
1002
1197
|
function sanitizeRelativePath(relativePath) {
|
|
1003
|
-
const trimmed = normalizePath
|
|
1198
|
+
const trimmed = normalizePath(relativePath.trim());
|
|
1004
1199
|
const sanitized = sanitizeFilePath(SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext)) ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`, false);
|
|
1005
|
-
const normalized = normalizePath
|
|
1200
|
+
const normalized = normalizePath(sanitized.path);
|
|
1006
1201
|
return {
|
|
1007
1202
|
relativePath: normalized,
|
|
1008
1203
|
extension: sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION
|
|
@@ -1061,6 +1256,7 @@ function tryGitInit(projectDir) {
|
|
|
1061
1256
|
debug("Already in a repository, skipping git init");
|
|
1062
1257
|
return false;
|
|
1063
1258
|
}
|
|
1259
|
+
status("Initializing git repository...");
|
|
1064
1260
|
execSync("git init", {
|
|
1065
1261
|
stdio: "ignore",
|
|
1066
1262
|
cwd: projectDir
|
|
@@ -1082,7 +1278,7 @@ function tryGitInit(projectDir) {
|
|
|
1082
1278
|
return true;
|
|
1083
1279
|
} catch (e) {
|
|
1084
1280
|
if (didInit) try {
|
|
1085
|
-
fs$
|
|
1281
|
+
fs$2.rmSync(path.join(projectDir, ".git"), {
|
|
1086
1282
|
recursive: true,
|
|
1087
1283
|
force: true
|
|
1088
1284
|
});
|
|
@@ -1250,9 +1446,21 @@ async function findSkillsSourceDir() {
|
|
|
1250
1446
|
const FETCH_TIMEOUT_MS = 6e4;
|
|
1251
1447
|
const MAX_FETCH_RETRIES = 3;
|
|
1252
1448
|
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
1253
|
-
const
|
|
1254
|
-
const
|
|
1255
|
-
|
|
1449
|
+
const FRAMER_PACKAGE_NAME = "framer";
|
|
1450
|
+
const CORE_LIBRARIES = [
|
|
1451
|
+
"framer-motion",
|
|
1452
|
+
"framer",
|
|
1453
|
+
"react",
|
|
1454
|
+
"react-dom"
|
|
1455
|
+
];
|
|
1456
|
+
/** Packages with pinned type versions — used by ATA's `// types:` comment syntax */
|
|
1457
|
+
const DEFAULT_PINNED_TYPE_VERSIONS = {
|
|
1458
|
+
"framer-motion": "12.34.3",
|
|
1459
|
+
react: "18.2.0",
|
|
1460
|
+
"react-dom": "18.2.0",
|
|
1461
|
+
"@types/react": "18.2.0",
|
|
1462
|
+
"@types/react-dom": "18.2.0"
|
|
1463
|
+
};
|
|
1256
1464
|
const JSON_EXTENSION_REGEX = /\.json$/i;
|
|
1257
1465
|
/**
|
|
1258
1466
|
* Packages that are officially supported for type acquisition.
|
|
@@ -1262,6 +1470,7 @@ const SUPPORTED_PACKAGES = new Set([
|
|
|
1262
1470
|
"framer",
|
|
1263
1471
|
"framer-motion",
|
|
1264
1472
|
"react",
|
|
1473
|
+
"react-dom",
|
|
1265
1474
|
"@types/react",
|
|
1266
1475
|
"eventemitter3",
|
|
1267
1476
|
"csstype",
|
|
@@ -1273,13 +1482,19 @@ const SUPPORTED_PACKAGES = new Set([
|
|
|
1273
1482
|
*/
|
|
1274
1483
|
var Installer = class {
|
|
1275
1484
|
projectDir;
|
|
1276
|
-
|
|
1485
|
+
npmStrategy;
|
|
1486
|
+
requestDependencyVersions;
|
|
1277
1487
|
ata;
|
|
1278
1488
|
processedImports = /* @__PURE__ */ new Set();
|
|
1489
|
+
packageManagerPackages = /* @__PURE__ */ new Set();
|
|
1490
|
+
packageJsonRefreshPromise = Promise.resolve();
|
|
1279
1491
|
initializationPromise = null;
|
|
1492
|
+
pinnedTypeVersions = { ...DEFAULT_PINNED_TYPE_VERSIONS };
|
|
1493
|
+
pinnedTypeVersionsPromise = null;
|
|
1280
1494
|
constructor(config) {
|
|
1281
1495
|
this.projectDir = config.projectDir;
|
|
1282
|
-
this.
|
|
1496
|
+
this.npmStrategy = config.npmStrategy ?? "none";
|
|
1497
|
+
this.requestDependencyVersions = config.requestDependencyVersions ?? (async (packages) => Object.fromEntries(packages.map((packageName) => [packageName, null])));
|
|
1283
1498
|
const seenPackages = /* @__PURE__ */ new Set();
|
|
1284
1499
|
this.ata = setupTypeAcquisition({
|
|
1285
1500
|
projectName: "framer-code-link",
|
|
@@ -1357,10 +1572,16 @@ var Installer = class {
|
|
|
1357
1572
|
this.ensureSkills(),
|
|
1358
1573
|
this.ensureGitignore()
|
|
1359
1574
|
]);
|
|
1575
|
+
if (this.npmStrategy === "package-manager") {
|
|
1576
|
+
await this.enqueuePackageJsonRefresh(await this.collectPackageManagerPackageNames());
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
this.pinnedTypeVersionsPromise = this.resolvePinnedTypeVersions();
|
|
1360
1580
|
Promise.resolve().then(async () => {
|
|
1361
|
-
await this.
|
|
1362
|
-
const
|
|
1363
|
-
await this.
|
|
1581
|
+
const coreImports = await this.buildPinnedImports(CORE_LIBRARIES);
|
|
1582
|
+
const packageJsonDeps = this.npmStrategy === "acquire-types" ? Object.keys(this.pinnedTypeVersions).filter((name) => !SUPPORTED_PACKAGES.has(name)) : [];
|
|
1583
|
+
const imports = [...coreImports, ...await this.buildPinnedImports(packageJsonDeps)].join("\n");
|
|
1584
|
+
await this.ata(imports);
|
|
1364
1585
|
}).catch((err) => {
|
|
1365
1586
|
debug("Type installation failed", err);
|
|
1366
1587
|
});
|
|
@@ -1368,19 +1589,84 @@ var Installer = class {
|
|
|
1368
1589
|
async processImports(fileName, content) {
|
|
1369
1590
|
const allImports = extractImports(content).filter((i) => i.type === "npm");
|
|
1370
1591
|
if (allImports.length === 0) return;
|
|
1371
|
-
|
|
1372
|
-
|
|
1592
|
+
if (this.npmStrategy === "package-manager") {
|
|
1593
|
+
await this.enqueuePackageJsonRefresh(allImports.map((imp) => getBasePackageName(imp.name)));
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const imports = this.npmStrategy === "acquire-types" ? allImports : allImports.filter((i) => this.isSupportedPackage(i.name));
|
|
1597
|
+
if (allImports.length - imports.length > 0 && this.npmStrategy !== "acquire-types") debug(`Skipping unsupported packages: ${allImports.filter((i) => !this.isSupportedPackage(i.name)).map((i) => i.name).join(", ")} (use --unsupported-npm to enable)`);
|
|
1373
1598
|
if (imports.length === 0) return;
|
|
1374
|
-
|
|
1599
|
+
await this.pinnedTypeVersionsPromise;
|
|
1600
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1601
|
+
const hash = imports.map((imp) => this.pinImport(imp.name)).sort().join(",");
|
|
1375
1602
|
if (this.processedImports.has(hash)) return;
|
|
1376
1603
|
this.processedImports.add(hash);
|
|
1377
1604
|
debug(`Processing imports for ${fileName} (${imports.length} packages)`);
|
|
1378
|
-
const filteredContent = this.
|
|
1605
|
+
const filteredContent = this.npmStrategy === "acquire-types" ? content : await this.buildFilteredImports(imports);
|
|
1379
1606
|
try {
|
|
1380
1607
|
await this.ata(filteredContent);
|
|
1381
1608
|
} catch (err) {
|
|
1382
|
-
warn(`
|
|
1609
|
+
warn(`Type fetching failed for ${fileName}`);
|
|
1610
|
+
debug(`ATA error for ${fileName}:`, err);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
async collectPackageManagerPackageNames() {
|
|
1614
|
+
const packageNames = new Set(CORE_LIBRARIES);
|
|
1615
|
+
await this.addPackageNamesFromDirectory(path.join(this.projectDir, "files"), packageNames);
|
|
1616
|
+
return [...packageNames];
|
|
1617
|
+
}
|
|
1618
|
+
async addPackageNamesFromDirectory(directory, packageNames) {
|
|
1619
|
+
let entries;
|
|
1620
|
+
try {
|
|
1621
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
1622
|
+
} catch {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
await Promise.all(entries.map(async (entry) => {
|
|
1626
|
+
const entryPath = path.join(directory, entry.name);
|
|
1627
|
+
if (entry.isDirectory()) {
|
|
1628
|
+
await this.addPackageNamesFromDirectory(entryPath, packageNames);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (!entry.isFile() || JSON_EXTENSION_REGEX.test(entry.name)) return;
|
|
1632
|
+
try {
|
|
1633
|
+
const content = await fs.readFile(entryPath, "utf-8");
|
|
1634
|
+
for (const imported of extractImports(content).filter((i) => i.type === "npm")) packageNames.add(getBasePackageName(imported.name));
|
|
1635
|
+
} catch {}
|
|
1636
|
+
}));
|
|
1637
|
+
}
|
|
1638
|
+
async enqueuePackageJsonRefresh(packageNames) {
|
|
1639
|
+
const missingPackageNames = packageNames.map((name) => getBasePackageName(name)).filter((name) => {
|
|
1640
|
+
if (this.packageManagerPackages.has(name)) return false;
|
|
1641
|
+
this.packageManagerPackages.add(name);
|
|
1642
|
+
return true;
|
|
1643
|
+
});
|
|
1644
|
+
if (missingPackageNames.length === 0) return this.packageJsonRefreshPromise;
|
|
1645
|
+
this.packageJsonRefreshPromise = this.packageJsonRefreshPromise.then(async () => {
|
|
1646
|
+
await this.refreshPackageJsonFromPlugin(missingPackageNames);
|
|
1647
|
+
}).catch((err) => {
|
|
1648
|
+
warn("Could not refresh package.json dependency versions", err);
|
|
1649
|
+
});
|
|
1650
|
+
return this.packageJsonRefreshPromise;
|
|
1651
|
+
}
|
|
1652
|
+
async refreshPackageJsonFromPlugin(packageNames) {
|
|
1653
|
+
const uniquePackageNames = [...new Set(packageNames)].sort();
|
|
1654
|
+
const versions = await this.requestDependencyVersions(uniquePackageNames);
|
|
1655
|
+
const packagePath = path.join(this.projectDir, "package.json");
|
|
1656
|
+
const raw = await fs.readFile(packagePath, "utf-8");
|
|
1657
|
+
const pkg = JSON.parse(raw);
|
|
1658
|
+
const dependencies = typeof pkg.dependencies === "object" && pkg.dependencies !== null && !Array.isArray(pkg.dependencies) ? { ...pkg.dependencies } : {};
|
|
1659
|
+
let changed = false;
|
|
1660
|
+
for (const packageName of uniquePackageNames) {
|
|
1661
|
+
const version = versions[packageName];
|
|
1662
|
+
if (!version || dependencies[packageName] === version) continue;
|
|
1663
|
+
dependencies[packageName] = version;
|
|
1664
|
+
changed = true;
|
|
1383
1665
|
}
|
|
1666
|
+
if (!changed) return;
|
|
1667
|
+
pkg.dependencies = dependencies;
|
|
1668
|
+
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 4));
|
|
1669
|
+
debug(`Updated package.json dependency versions for ${uniquePackageNames.join(", ")}`);
|
|
1384
1670
|
}
|
|
1385
1671
|
/**
|
|
1386
1672
|
* Check if a package is in the supported list.
|
|
@@ -1388,14 +1674,61 @@ var Installer = class {
|
|
|
1388
1674
|
*/
|
|
1389
1675
|
isSupportedPackage(pkgName) {
|
|
1390
1676
|
if (SUPPORTED_PACKAGES.has(pkgName)) return true;
|
|
1391
|
-
const basePkg =
|
|
1677
|
+
const basePkg = getBasePackageName(pkgName);
|
|
1392
1678
|
return SUPPORTED_PACKAGES.has(basePkg);
|
|
1393
1679
|
}
|
|
1394
1680
|
/**
|
|
1395
1681
|
* Build synthetic import statements for ATA from filtered imports
|
|
1396
1682
|
*/
|
|
1397
|
-
buildFilteredImports(imports) {
|
|
1398
|
-
return imports.map((imp) =>
|
|
1683
|
+
async buildFilteredImports(imports) {
|
|
1684
|
+
return (await this.buildPinnedImports(imports.map((imp) => imp.name))).join("\n");
|
|
1685
|
+
}
|
|
1686
|
+
async buildPinnedImports(imports) {
|
|
1687
|
+
await this.pinnedTypeVersionsPromise;
|
|
1688
|
+
return imports.map((name) => this.pinImport(name));
|
|
1689
|
+
}
|
|
1690
|
+
async resolvePinnedTypeVersions() {
|
|
1691
|
+
try {
|
|
1692
|
+
const framerManifest = await fetchNpmPackageManifest(FRAMER_PACKAGE_NAME);
|
|
1693
|
+
const framerVersion = normalizePinnedVersion(framerManifest.version);
|
|
1694
|
+
if (framerVersion) this.pinnedTypeVersions.framer = framerVersion;
|
|
1695
|
+
for (const [pkg, defaultVersion] of Object.entries(DEFAULT_PINNED_TYPE_VERSIONS)) {
|
|
1696
|
+
const manifestDep = pkg.replace(/^@types\//, "");
|
|
1697
|
+
this.pinnedTypeVersions[pkg] = normalizePinnedVersion(getManifestDependencyVersion(framerManifest, manifestDep)) ?? defaultVersion;
|
|
1698
|
+
}
|
|
1699
|
+
debug(`Resolved ATA pins from ${FRAMER_PACKAGE_NAME}@${framerVersion ?? "latest"} (framer-motion ${this.pinnedTypeVersions["framer-motion"]}, react ${this.pinnedTypeVersions.react})`);
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err);
|
|
1702
|
+
}
|
|
1703
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1704
|
+
}
|
|
1705
|
+
async resolvePackageJsonPins() {
|
|
1706
|
+
try {
|
|
1707
|
+
const pkgPath = path.join(this.projectDir, "package.json");
|
|
1708
|
+
const raw = await fs.readFile(pkgPath, "utf-8");
|
|
1709
|
+
const pkg = JSON.parse(raw);
|
|
1710
|
+
const allDeps = {
|
|
1711
|
+
...pkg.dependencies ?? {},
|
|
1712
|
+
...pkg.devDependencies ?? {}
|
|
1713
|
+
};
|
|
1714
|
+
for (const [name, range] of Object.entries(allDeps)) {
|
|
1715
|
+
const version = normalizePinnedVersion(range);
|
|
1716
|
+
if (version) this.pinnedTypeVersions[name] = version;
|
|
1717
|
+
}
|
|
1718
|
+
debug(`Resolved ${Object.keys(allDeps).length} package.json version pins`);
|
|
1719
|
+
} catch {
|
|
1720
|
+
warn("Could not read package.json for version pinning");
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Build an import statement with an optional `// types:` version pin for ATA.
|
|
1725
|
+
* Resolves the base package name for subpath imports (e.g., "framer-motion/dist" -> "framer-motion").
|
|
1726
|
+
*/
|
|
1727
|
+
pinImport(name) {
|
|
1728
|
+
const base = getBasePackageName(name);
|
|
1729
|
+
const version = this.pinnedTypeVersions[base];
|
|
1730
|
+
if (version) return `import "${name}"; // types: ${version}`;
|
|
1731
|
+
return `import "${name}";`;
|
|
1399
1732
|
}
|
|
1400
1733
|
async writeTypeFile(receivedPath, code) {
|
|
1401
1734
|
const normalized = receivedPath.replace(/^\//, "");
|
|
@@ -1419,7 +1752,8 @@ var Installer = class {
|
|
|
1419
1752
|
const response = await fetch(`https://registry.npmjs.org/${pkgName}`);
|
|
1420
1753
|
if (!response.ok) return;
|
|
1421
1754
|
const npmData = await response.json();
|
|
1422
|
-
const
|
|
1755
|
+
const pinnedVersion = this.pinnedTypeVersions[pkgName];
|
|
1756
|
+
const version = pinnedVersion ? this.findMatchingVersion(Object.keys(npmData.versions ?? {}), pinnedVersion) : npmData["dist-tags"]?.latest;
|
|
1423
1757
|
if (!version || !npmData.versions?.[version]) return;
|
|
1424
1758
|
const pkg = npmData.versions[version];
|
|
1425
1759
|
if (pkg.exports) for (const key of Object.keys(pkg.exports)) pkg.exports[key] = fixExportTypes(pkg.exports[key]);
|
|
@@ -1427,6 +1761,17 @@ var Installer = class {
|
|
|
1427
1761
|
await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2));
|
|
1428
1762
|
} catch {}
|
|
1429
1763
|
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Find the best matching version from a list of available versions.
|
|
1766
|
+
* Supports exact versions ("18.2.0") — returns exact match if available.
|
|
1767
|
+
*/
|
|
1768
|
+
findMatchingVersion(versions, pinned) {
|
|
1769
|
+
if (versions.includes(pinned)) return pinned;
|
|
1770
|
+
const [major, minor] = pinned.split(".");
|
|
1771
|
+
const prefix = `${major}.${minor}.`;
|
|
1772
|
+
const matching = versions.filter((v) => v.startsWith(prefix));
|
|
1773
|
+
return matching.length > 0 ? matching[matching.length - 1] : void 0;
|
|
1774
|
+
}
|
|
1430
1775
|
async ensureTsConfig() {
|
|
1431
1776
|
const tsconfigPath = path.join(this.projectDir, "tsconfig.json");
|
|
1432
1777
|
try {
|
|
@@ -1502,7 +1847,7 @@ declare module "*.json"
|
|
|
1502
1847
|
private: true,
|
|
1503
1848
|
description: "Framer files synced with framer-code-link"
|
|
1504
1849
|
};
|
|
1505
|
-
await fs.writeFile(packagePath, JSON.stringify(pkg, null,
|
|
1850
|
+
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 4));
|
|
1506
1851
|
debug("Created package.json");
|
|
1507
1852
|
}
|
|
1508
1853
|
}
|
|
@@ -1532,61 +1877,24 @@ declare module "*.json"
|
|
|
1532
1877
|
await fs.writeFile(gitignorePath, content);
|
|
1533
1878
|
debug("Created .gitignore");
|
|
1534
1879
|
}
|
|
1535
|
-
async ensureReact18Types() {
|
|
1536
|
-
const reactTypesDir = path.join(this.projectDir, "node_modules/@types/react");
|
|
1537
|
-
const reactFiles = [
|
|
1538
|
-
"package.json",
|
|
1539
|
-
"index.d.ts",
|
|
1540
|
-
"global.d.ts",
|
|
1541
|
-
"jsx-runtime.d.ts",
|
|
1542
|
-
"jsx-dev-runtime.d.ts"
|
|
1543
|
-
];
|
|
1544
|
-
if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) debug("📦 React types (from cache)");
|
|
1545
|
-
else {
|
|
1546
|
-
debug("Downloading React 18 types...");
|
|
1547
|
-
await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
|
|
1548
|
-
}
|
|
1549
|
-
const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
|
|
1550
|
-
const reactDomFiles = [
|
|
1551
|
-
"package.json",
|
|
1552
|
-
"index.d.ts",
|
|
1553
|
-
"client.d.ts"
|
|
1554
|
-
];
|
|
1555
|
-
if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) debug("📦 React DOM types (from cache)");
|
|
1556
|
-
else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
|
|
1557
|
-
}
|
|
1558
|
-
async hasTypePackage(destinationDir, version, files) {
|
|
1559
|
-
try {
|
|
1560
|
-
const pkgJsonPath = path.join(destinationDir, "package.json");
|
|
1561
|
-
const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
|
|
1562
|
-
if (JSON.parse(pkgJson).version !== version) return false;
|
|
1563
|
-
for (const file of files) {
|
|
1564
|
-
if (file === "package.json") continue;
|
|
1565
|
-
await fs.access(path.join(destinationDir, file));
|
|
1566
|
-
}
|
|
1567
|
-
return true;
|
|
1568
|
-
} catch {
|
|
1569
|
-
return false;
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
async downloadTypePackage(pkgName, version, destinationDir, files) {
|
|
1573
|
-
const baseUrl = `https://unpkg.com/${pkgName}@${version}`;
|
|
1574
|
-
await fs.mkdir(destinationDir, { recursive: true });
|
|
1575
|
-
await Promise.all(files.map(async (file) => {
|
|
1576
|
-
const destination = path.join(destinationDir, file);
|
|
1577
|
-
try {
|
|
1578
|
-
await fs.access(destination);
|
|
1579
|
-
return;
|
|
1580
|
-
} catch {}
|
|
1581
|
-
try {
|
|
1582
|
-
const response = await fetch(`${baseUrl}/${file}`);
|
|
1583
|
-
if (!response.ok) return;
|
|
1584
|
-
const content = await response.text();
|
|
1585
|
-
await fs.writeFile(destination, content);
|
|
1586
|
-
} catch {}
|
|
1587
|
-
}));
|
|
1588
|
-
}
|
|
1589
1880
|
};
|
|
1881
|
+
function getManifestDependencyVersion(manifest, packageName) {
|
|
1882
|
+
return manifest.peerDependencies?.[packageName] ?? manifest.dependencies?.[packageName];
|
|
1883
|
+
}
|
|
1884
|
+
function getBasePackageName(packageName) {
|
|
1885
|
+
const parts = packageName.split("/");
|
|
1886
|
+
if (packageName.startsWith("@")) return parts.length >= 2 ? parts.slice(0, 2).join("/") : packageName;
|
|
1887
|
+
return parts[0] ?? packageName;
|
|
1888
|
+
}
|
|
1889
|
+
function normalizePinnedVersion(version) {
|
|
1890
|
+
if (!version) return void 0;
|
|
1891
|
+
return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(version)?.[0];
|
|
1892
|
+
}
|
|
1893
|
+
async function fetchNpmPackageManifest(packageName) {
|
|
1894
|
+
const response = await fetchWithRetry(`https://registry.npmjs.org/${packageName}/latest`);
|
|
1895
|
+
if (!response.ok) throw new Error(`Failed to fetch ${packageName} manifest: ${response.status}`);
|
|
1896
|
+
return await response.json();
|
|
1897
|
+
}
|
|
1590
1898
|
/**
|
|
1591
1899
|
* Transform package.json exports to include .d.ts type paths
|
|
1592
1900
|
*/
|
|
@@ -1634,11 +1942,12 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
|
1634
1942
|
if (isRetryable) checkFatalFailure(urlString);
|
|
1635
1943
|
if (attempt < retries && isRetryable) {
|
|
1636
1944
|
const delay = attempt * 1e3;
|
|
1637
|
-
|
|
1945
|
+
debug(`Fetch failed for ${urlString}, retrying...`, error);
|
|
1638
1946
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1639
1947
|
continue;
|
|
1640
1948
|
}
|
|
1641
|
-
warn(`Fetch failed for ${urlString}
|
|
1949
|
+
warn(`Fetch failed for ${urlString}`);
|
|
1950
|
+
debug(`Fetch error details:`, error);
|
|
1642
1951
|
throw error;
|
|
1643
1952
|
}
|
|
1644
1953
|
}
|
|
@@ -1646,141 +1955,104 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
|
1646
1955
|
}
|
|
1647
1956
|
|
|
1648
1957
|
//#endregion
|
|
1649
|
-
//#region src/helpers/
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
awaitAction(actionId, description) {
|
|
1662
|
-
return new Promise((resolve, reject) => {
|
|
1663
|
-
this.pendingActions.set(actionId, {
|
|
1664
|
-
resolve,
|
|
1665
|
-
reject
|
|
1666
|
-
});
|
|
1667
|
-
debug(`Awaiting ${description}: ${actionId}`);
|
|
1668
|
-
});
|
|
1669
|
-
}
|
|
1670
|
-
/**
|
|
1671
|
-
* Sends the delete request to the plugin and awaits the user's decision.
|
|
1672
|
-
* Returns the list of fileNames that were confirmed for deletion.
|
|
1673
|
-
*/
|
|
1674
|
-
async requestDeleteDecision(socket, { fileNames, requireConfirmation }) {
|
|
1675
|
-
if (!socket) throw new Error("Cannot request delete decision: plugin not connected");
|
|
1676
|
-
if (fileNames.length === 0) return [];
|
|
1677
|
-
if (requireConfirmation) {
|
|
1678
|
-
const confirmationPromises = fileNames.map((fileName) => this.awaitAction(`delete:${fileName}`, "delete confirmation").then((confirmed) => confirmed ? fileName : null).catch((err) => {
|
|
1679
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1680
|
-
debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
|
|
1681
|
-
return null;
|
|
1682
|
-
}
|
|
1683
|
-
throw err;
|
|
1684
|
-
}));
|
|
1685
|
-
await sendMessage(socket, {
|
|
1686
|
-
type: "file-delete",
|
|
1687
|
-
fileNames,
|
|
1688
|
-
requireConfirmation: true
|
|
1689
|
-
});
|
|
1690
|
-
return (await Promise.all(confirmationPromises)).filter((name) => name !== null);
|
|
1691
|
-
}
|
|
1692
|
-
await sendMessage(socket, {
|
|
1693
|
-
type: "file-delete",
|
|
1694
|
-
fileNames,
|
|
1695
|
-
requireConfirmation: false
|
|
1696
|
-
});
|
|
1697
|
-
return fileNames;
|
|
1958
|
+
//#region src/helpers/npm-strategy.ts
|
|
1959
|
+
const CONFIG_FIELD = "codeLinkNpmStrategy";
|
|
1960
|
+
const LOCKFILES = [
|
|
1961
|
+
"yarn.lock",
|
|
1962
|
+
"pnpm-lock.yaml",
|
|
1963
|
+
"package-lock.json",
|
|
1964
|
+
"bun.lockb"
|
|
1965
|
+
];
|
|
1966
|
+
async function resolveNpmStrategy(config, projectDir) {
|
|
1967
|
+
if (config.npmStrategy) {
|
|
1968
|
+
debug(`Using npm strategy from CLI flag: ${config.npmStrategy}`);
|
|
1969
|
+
return config.npmStrategy;
|
|
1698
1970
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
if (!socket) throw new Error("Cannot request conflict decision: plugin not connected");
|
|
1704
|
-
if (conflicts.length === 0) return /* @__PURE__ */ new Map();
|
|
1705
|
-
const pending = conflicts.map((conflict) => ({
|
|
1706
|
-
fileName: conflict.fileName,
|
|
1707
|
-
promise: this.awaitAction(`conflict:${conflict.fileName}`, "conflict resolution")
|
|
1708
|
-
}));
|
|
1709
|
-
await sendMessage(socket, {
|
|
1710
|
-
type: "conflicts-detected",
|
|
1711
|
-
conflicts
|
|
1712
|
-
});
|
|
1713
|
-
try {
|
|
1714
|
-
const results = await Promise.all(pending.map(async ({ fileName, promise }) => [fileName, await promise]));
|
|
1715
|
-
return new Map(results);
|
|
1716
|
-
} catch (err) {
|
|
1717
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1718
|
-
debug("Plugin disconnected while awaiting conflict decisions");
|
|
1719
|
-
return /* @__PURE__ */ new Map();
|
|
1720
|
-
}
|
|
1721
|
-
throw err;
|
|
1722
|
-
}
|
|
1971
|
+
const packageJsonStrategy = await readPackageJsonStrategy(projectDir);
|
|
1972
|
+
if (packageJsonStrategy) {
|
|
1973
|
+
debug(`Using npm strategy from package.json ${CONFIG_FIELD}: ${packageJsonStrategy}`);
|
|
1974
|
+
return packageJsonStrategy;
|
|
1723
1975
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
const pending = this.pendingActions.get(actionId);
|
|
1729
|
-
if (!pending) {
|
|
1730
|
-
debug(`Unexpected confirmation for ${actionId}`);
|
|
1731
|
-
return false;
|
|
1732
|
-
}
|
|
1733
|
-
this.pendingActions.delete(actionId);
|
|
1734
|
-
pending.resolve(value);
|
|
1735
|
-
debug(`Confirmed: ${actionId}`);
|
|
1736
|
-
return true;
|
|
1976
|
+
const detectedLockfile = await detectLockfile(projectDir);
|
|
1977
|
+
if (detectedLockfile) {
|
|
1978
|
+
debug(`Using npm strategy package-manager from ${detectedLockfile}`);
|
|
1979
|
+
return "package-manager";
|
|
1737
1980
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1981
|
+
debug("Using default npm strategy: none");
|
|
1982
|
+
return "none";
|
|
1983
|
+
}
|
|
1984
|
+
async function readPackageJsonStrategy(projectDir) {
|
|
1985
|
+
try {
|
|
1986
|
+
const raw = await fs.readFile(path.join(projectDir, "package.json"), "utf-8");
|
|
1987
|
+
const strategy = JSON.parse(raw).codeLinkNpmStrategy;
|
|
1988
|
+
if (strategy === void 0) return null;
|
|
1989
|
+
if (isNpmStrategy(strategy)) return strategy;
|
|
1990
|
+
warn(`Ignoring invalid package.json ${CONFIG_FIELD}: ${String(strategy)}`);
|
|
1991
|
+
return null;
|
|
1992
|
+
} catch {
|
|
1993
|
+
return null;
|
|
1747
1994
|
}
|
|
1995
|
+
}
|
|
1996
|
+
async function detectLockfile(projectDir) {
|
|
1997
|
+
for (const fileName of LOCKFILES) try {
|
|
1998
|
+
await fs.access(path.join(projectDir, fileName));
|
|
1999
|
+
return fileName;
|
|
2000
|
+
} catch {}
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
function isNpmStrategy(value) {
|
|
2004
|
+
return value === "none" || value === "acquire-types" || value === "package-manager";
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
//#endregion
|
|
2008
|
+
//#region src/scheduler.ts
|
|
2009
|
+
const TIMINGS = {
|
|
2010
|
+
disconnectNotice: 4e3,
|
|
2011
|
+
expectedDeleteEchoExpiry: 5e3,
|
|
2012
|
+
renameBuffer: 100,
|
|
2013
|
+
sanitizationEchoExpiry: 300
|
|
1748
2014
|
};
|
|
2015
|
+
function timerId(task, key) {
|
|
2016
|
+
return key !== void 0 ? `${task}:${key}` : task;
|
|
2017
|
+
}
|
|
2018
|
+
function createScheduler() {
|
|
2019
|
+
const timers = /* @__PURE__ */ new Map();
|
|
2020
|
+
return {
|
|
2021
|
+
after(task, delayMs, fn, key) {
|
|
2022
|
+
const id = timerId(task, key);
|
|
2023
|
+
const existing = timers.get(id);
|
|
2024
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
2025
|
+
const handle = setTimeout(() => {
|
|
2026
|
+
timers.delete(id);
|
|
2027
|
+
fn();
|
|
2028
|
+
}, delayMs);
|
|
2029
|
+
timers.set(id, handle);
|
|
2030
|
+
},
|
|
2031
|
+
cancel(task, key) {
|
|
2032
|
+
const id = timerId(task, key);
|
|
2033
|
+
const handle = timers.get(id);
|
|
2034
|
+
if (handle !== void 0) {
|
|
2035
|
+
clearTimeout(handle);
|
|
2036
|
+
timers.delete(id);
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
cancelAll() {
|
|
2040
|
+
for (const handle of timers.values()) clearTimeout(handle);
|
|
2041
|
+
timers.clear();
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
1749
2045
|
|
|
1750
2046
|
//#endregion
|
|
1751
|
-
//#region src/
|
|
2047
|
+
//#region src/utils/node-paths.ts
|
|
1752
2048
|
/**
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
* During watching mode, we trust remote changes and apply them immediately.
|
|
1756
|
-
* During snapshot_processing, we queue them for later (to avoid race conditions).
|
|
1757
|
-
*
|
|
1758
|
-
* Note: This is for INCOMING changes from remote. Local changes (from watcher)
|
|
1759
|
-
* are handled separately and always sent during watching mode.
|
|
2049
|
+
* Path manipulation utilities
|
|
1760
2050
|
*/
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
if (currentMode === "watching") {
|
|
1767
|
-
if (!fileMeta) return {
|
|
1768
|
-
action: "apply",
|
|
1769
|
-
reason: "new-file"
|
|
1770
|
-
};
|
|
1771
|
-
return {
|
|
1772
|
-
action: "apply",
|
|
1773
|
-
reason: "safe-update"
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
if (currentMode === "conflict_resolution") return {
|
|
1777
|
-
action: "queue",
|
|
1778
|
-
reason: "snapshot-in-progress"
|
|
1779
|
-
};
|
|
1780
|
-
return {
|
|
1781
|
-
action: "reject",
|
|
1782
|
-
reason: "unknown-file"
|
|
1783
|
-
};
|
|
2051
|
+
/**
|
|
2052
|
+
* Gets a relative path from the project directory
|
|
2053
|
+
*/
|
|
2054
|
+
function getRelativePath(projectDir, absolutePath) {
|
|
2055
|
+
return path.relative(projectDir, absolutePath);
|
|
1784
2056
|
}
|
|
1785
2057
|
|
|
1786
2058
|
//#endregion
|
|
@@ -1790,47 +2062,240 @@ function validateIncomingChange(fileMeta, currentMode) {
|
|
|
1790
2062
|
*
|
|
1791
2063
|
* Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
|
|
1792
2064
|
*/
|
|
2065
|
+
function findUniqueHashMatch(pendingItems, contentHash) {
|
|
2066
|
+
let matchingKey;
|
|
2067
|
+
for (const [key, pending] of pendingItems) {
|
|
2068
|
+
if (pending.contentHash !== contentHash) continue;
|
|
2069
|
+
if (matchingKey !== void 0) return;
|
|
2070
|
+
matchingKey = key;
|
|
2071
|
+
}
|
|
2072
|
+
return matchingKey;
|
|
2073
|
+
}
|
|
2074
|
+
function matchPendingAddForDelete(contentHash, pendingAdds) {
|
|
2075
|
+
if (!contentHash) return null;
|
|
2076
|
+
const matchingAddKey = findUniqueHashMatch(pendingAdds, contentHash);
|
|
2077
|
+
if (!matchingAddKey) return null;
|
|
2078
|
+
const pendingAdd = pendingAdds.get(matchingAddKey);
|
|
2079
|
+
if (!pendingAdd) return null;
|
|
2080
|
+
return {
|
|
2081
|
+
key: matchingAddKey,
|
|
2082
|
+
pendingAdd
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function matchPendingDeleteForAdd(contentHash, pendingDeletes) {
|
|
2086
|
+
const matchingDeleteKey = findUniqueHashMatch(pendingDeletes, contentHash);
|
|
2087
|
+
if (!matchingDeleteKey) return null;
|
|
2088
|
+
const pendingDelete = pendingDeletes.get(matchingDeleteKey);
|
|
2089
|
+
if (!pendingDelete) return null;
|
|
2090
|
+
return {
|
|
2091
|
+
key: matchingDeleteKey,
|
|
2092
|
+
pendingDelete
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
1793
2095
|
/**
|
|
1794
2096
|
* Initializes a file watcher for the given directory
|
|
1795
2097
|
*/
|
|
1796
2098
|
function initWatcher(filesDir) {
|
|
1797
2099
|
const handlers = [];
|
|
2100
|
+
const scheduler = createScheduler();
|
|
2101
|
+
const contentHashCache = /* @__PURE__ */ new Map();
|
|
2102
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
2103
|
+
const pendingAdds = /* @__PURE__ */ new Map();
|
|
2104
|
+
const recentSanitizations = /* @__PURE__ */ new Set();
|
|
1798
2105
|
const watcher = chokidar.watch(filesDir, {
|
|
1799
2106
|
ignored: /(^|[/\\])\.\./,
|
|
1800
2107
|
persistent: true,
|
|
1801
2108
|
ignoreInitial: false
|
|
1802
2109
|
});
|
|
1803
2110
|
debug(`Watching directory: ${filesDir}`);
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
2111
|
+
const dispatchEvent = (event) => {
|
|
2112
|
+
let eventToDispatch = event;
|
|
2113
|
+
if (event.kind === "rename" && event.relativePath === event.oldRelativePath) {
|
|
2114
|
+
if (event.content === void 0) {
|
|
2115
|
+
warn(`Skipping invalid same-path rename without content: ${event.relativePath}`);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
debug(`Converting same-path rename to change: ${event.relativePath}`);
|
|
2119
|
+
eventToDispatch = {
|
|
2120
|
+
kind: "change",
|
|
2121
|
+
relativePath: event.relativePath,
|
|
2122
|
+
content: event.content
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
debug(`Watcher event: ${eventToDispatch.kind} ${eventToDispatch.relativePath}`);
|
|
2126
|
+
for (const handler of handlers) handler(eventToDispatch);
|
|
2127
|
+
};
|
|
2128
|
+
/**
|
|
2129
|
+
* Resolves the relative path identity for a watcher event.
|
|
2130
|
+
* Only "add" may rewrite that identity by successfully sanitizing on disk.
|
|
2131
|
+
*/
|
|
2132
|
+
const resolveRelativePath = async (kind, absolutePath) => {
|
|
2133
|
+
if (!isSupportedExtension$1(absolutePath)) return null;
|
|
2134
|
+
const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
|
|
2135
|
+
let relativePath = rawRelativePath;
|
|
1808
2136
|
let effectiveAbsolutePath = absolutePath;
|
|
1809
|
-
if (
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2137
|
+
if (kind === "add") {
|
|
2138
|
+
const sanitized = sanitizeFilePath(rawRelativePath, false);
|
|
2139
|
+
if (sanitized.path !== rawRelativePath) {
|
|
2140
|
+
const nextRelativePath = sanitized.path;
|
|
2141
|
+
const newAbsolutePath = path.join(filesDir, nextRelativePath);
|
|
2142
|
+
try {
|
|
2143
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
|
|
2144
|
+
await fs.rename(absolutePath, newAbsolutePath);
|
|
2145
|
+
debug(`Renamed ${rawRelativePath} -> ${nextRelativePath}`);
|
|
2146
|
+
relativePath = nextRelativePath;
|
|
2147
|
+
effectiveAbsolutePath = newAbsolutePath;
|
|
2148
|
+
recentSanitizations.add(rawRelativePath);
|
|
2149
|
+
recentSanitizations.add(nextRelativePath);
|
|
2150
|
+
scheduler.after("sanitizationEchoExpiry", TIMINGS.sanitizationEchoExpiry, () => {
|
|
2151
|
+
recentSanitizations.delete(rawRelativePath);
|
|
2152
|
+
recentSanitizations.delete(nextRelativePath);
|
|
2153
|
+
}, `${rawRelativePath}\0${nextRelativePath}`);
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
warn(`Failed to rename ${rawRelativePath}`, err);
|
|
2156
|
+
return {
|
|
2157
|
+
relativePath: rawRelativePath,
|
|
2158
|
+
effectiveAbsolutePath: absolutePath
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
1818
2161
|
}
|
|
1819
2162
|
}
|
|
2163
|
+
return {
|
|
2164
|
+
relativePath,
|
|
2165
|
+
effectiveAbsolutePath
|
|
2166
|
+
};
|
|
2167
|
+
};
|
|
2168
|
+
const emitEvent = async (kind, absolutePath) => {
|
|
2169
|
+
const rawRelPath = normalizePath(getRelativePath(filesDir, absolutePath));
|
|
2170
|
+
if (recentSanitizations.delete(rawRelPath)) {
|
|
2171
|
+
debug(`Suppressing sanitization echo: ${kind} ${rawRelPath}`);
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
const resolved = await resolveRelativePath(kind, absolutePath);
|
|
2175
|
+
if (!resolved) return;
|
|
2176
|
+
const { relativePath, effectiveAbsolutePath } = resolved;
|
|
2177
|
+
if (kind === "delete") {
|
|
2178
|
+
const lastHash = contentHashCache.get(relativePath);
|
|
2179
|
+
contentHashCache.delete(relativePath);
|
|
2180
|
+
const samePathPendingAdd = pendingAdds.get(relativePath);
|
|
2181
|
+
if (samePathPendingAdd) {
|
|
2182
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2183
|
+
pendingAdds.delete(relativePath);
|
|
2184
|
+
try {
|
|
2185
|
+
const latestContent = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
2186
|
+
const latestHash = hashFileContent(latestContent);
|
|
2187
|
+
contentHashCache.set(relativePath, latestHash);
|
|
2188
|
+
dispatchEvent({
|
|
2189
|
+
kind: "change",
|
|
2190
|
+
relativePath,
|
|
2191
|
+
content: latestContent
|
|
2192
|
+
});
|
|
2193
|
+
} catch {
|
|
2194
|
+
if (samePathPendingAdd.previousContentHash !== void 0) dispatchEvent({
|
|
2195
|
+
kind: "delete",
|
|
2196
|
+
relativePath
|
|
2197
|
+
});
|
|
2198
|
+
else debug(`Suppressing transient add+delete: ${relativePath}`);
|
|
2199
|
+
}
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
const matchedAdd = matchPendingAddForDelete(lastHash, pendingAdds);
|
|
2203
|
+
if (matchedAdd) {
|
|
2204
|
+
scheduler.cancel("renameBuffer", matchedAdd.key);
|
|
2205
|
+
pendingAdds.delete(matchedAdd.key);
|
|
2206
|
+
dispatchEvent({
|
|
2207
|
+
kind: "rename",
|
|
2208
|
+
relativePath: matchedAdd.pendingAdd.relativePath,
|
|
2209
|
+
oldRelativePath: relativePath,
|
|
2210
|
+
content: matchedAdd.pendingAdd.content
|
|
2211
|
+
});
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
if (lastHash) {
|
|
2215
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2216
|
+
pendingDeletes.delete(relativePath);
|
|
2217
|
+
dispatchEvent({
|
|
2218
|
+
kind: "delete",
|
|
2219
|
+
relativePath
|
|
2220
|
+
});
|
|
2221
|
+
}, relativePath);
|
|
2222
|
+
pendingDeletes.set(relativePath, {
|
|
2223
|
+
relativePath,
|
|
2224
|
+
contentHash: lastHash
|
|
2225
|
+
});
|
|
2226
|
+
} else dispatchEvent({
|
|
2227
|
+
kind: "delete",
|
|
2228
|
+
relativePath
|
|
2229
|
+
});
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
1820
2232
|
let content;
|
|
1821
|
-
|
|
2233
|
+
try {
|
|
1822
2234
|
content = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
1823
2235
|
} catch (err) {
|
|
1824
2236
|
debug(`Failed to read file ${relativePath}:`, err);
|
|
1825
2237
|
return;
|
|
1826
2238
|
}
|
|
1827
|
-
const
|
|
2239
|
+
const previousContentHash = contentHashCache.get(relativePath);
|
|
2240
|
+
const contentHash = hashFileContent(content);
|
|
2241
|
+
contentHashCache.set(relativePath, contentHash);
|
|
2242
|
+
if (kind === "add") {
|
|
2243
|
+
if (pendingDeletes.get(relativePath)) {
|
|
2244
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2245
|
+
pendingDeletes.delete(relativePath);
|
|
2246
|
+
dispatchEvent({
|
|
2247
|
+
kind: "change",
|
|
2248
|
+
relativePath,
|
|
2249
|
+
content
|
|
2250
|
+
});
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
const matchedDelete = matchPendingDeleteForAdd(contentHash, pendingDeletes);
|
|
2254
|
+
if (matchedDelete) {
|
|
2255
|
+
scheduler.cancel("renameBuffer", matchedDelete.key);
|
|
2256
|
+
pendingDeletes.delete(matchedDelete.key);
|
|
2257
|
+
dispatchEvent({
|
|
2258
|
+
kind: "rename",
|
|
2259
|
+
relativePath,
|
|
2260
|
+
oldRelativePath: matchedDelete.pendingDelete.relativePath,
|
|
2261
|
+
content
|
|
2262
|
+
});
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
const existingPendingAdd = pendingAdds.get(relativePath);
|
|
2266
|
+
if (existingPendingAdd) scheduler.cancel("renameBuffer", relativePath);
|
|
2267
|
+
const retainedPreviousContentHash = existingPendingAdd ? existingPendingAdd.previousContentHash : previousContentHash;
|
|
2268
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2269
|
+
pendingAdds.delete(relativePath);
|
|
2270
|
+
dispatchEvent({
|
|
2271
|
+
kind: "add",
|
|
2272
|
+
relativePath,
|
|
2273
|
+
content
|
|
2274
|
+
});
|
|
2275
|
+
}, relativePath);
|
|
2276
|
+
pendingAdds.set(relativePath, {
|
|
2277
|
+
relativePath,
|
|
2278
|
+
contentHash,
|
|
2279
|
+
content,
|
|
2280
|
+
previousContentHash: retainedPreviousContentHash
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
if (pendingAdds.get(relativePath)) {
|
|
2285
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2286
|
+
pendingAdds.delete(relativePath);
|
|
2287
|
+
dispatchEvent({
|
|
2288
|
+
kind: "add",
|
|
2289
|
+
relativePath,
|
|
2290
|
+
content
|
|
2291
|
+
});
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
dispatchEvent({
|
|
1828
2295
|
kind,
|
|
1829
2296
|
relativePath,
|
|
1830
2297
|
content
|
|
1831
|
-
};
|
|
1832
|
-
debug(`Watcher event: ${kind} ${relativePath}`);
|
|
1833
|
-
for (const handler of handlers) handler(event);
|
|
2298
|
+
});
|
|
1834
2299
|
};
|
|
1835
2300
|
watcher.on("add", (filePath) => {
|
|
1836
2301
|
emitEvent("add", filePath);
|
|
@@ -1846,6 +2311,11 @@ function initWatcher(filesDir) {
|
|
|
1846
2311
|
handlers.push(handler);
|
|
1847
2312
|
},
|
|
1848
2313
|
async close() {
|
|
2314
|
+
scheduler.cancelAll();
|
|
2315
|
+
pendingDeletes.clear();
|
|
2316
|
+
pendingAdds.clear();
|
|
2317
|
+
contentHashCache.clear();
|
|
2318
|
+
recentSanitizations.clear();
|
|
1849
2319
|
await watcher.close();
|
|
1850
2320
|
}
|
|
1851
2321
|
};
|
|
@@ -1935,58 +2405,380 @@ var FileMetadataCache = class {
|
|
|
1935
2405
|
};
|
|
1936
2406
|
|
|
1937
2407
|
//#endregion
|
|
1938
|
-
//#region src/
|
|
2408
|
+
//#region src/sync-memory.ts
|
|
1939
2409
|
/**
|
|
1940
|
-
*
|
|
2410
|
+
* SyncMemory owns file-level sync truth.
|
|
1941
2411
|
*
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
2412
|
+
* If a race depends on path normalization, content echoes, expected delete echoes,
|
|
2413
|
+
* or agreed metadata, it belongs here. Controller/apply code should call these
|
|
2414
|
+
* named operations instead of touching the underlying maps directly.
|
|
1944
2415
|
*/
|
|
2416
|
+
var SyncMemory = class {
|
|
2417
|
+
metadata = new FileMetadataCache();
|
|
2418
|
+
contentEchoes = /* @__PURE__ */ new Map();
|
|
2419
|
+
expectedDeleteEchoes = /* @__PURE__ */ new Set();
|
|
2420
|
+
scheduler = createScheduler();
|
|
2421
|
+
normalizePath(filePath) {
|
|
2422
|
+
return normalizeCodeFilePathWithExtension(filePath);
|
|
2423
|
+
}
|
|
2424
|
+
metadataFor(filePath) {
|
|
2425
|
+
return this.metadata.get(filePath);
|
|
2426
|
+
}
|
|
2427
|
+
persistedSnapshot() {
|
|
2428
|
+
return this.metadata.getPersistedState();
|
|
2429
|
+
}
|
|
2430
|
+
recordSyncedContent(filePath, content, modifiedAt) {
|
|
2431
|
+
this.metadata.recordSyncedSnapshot(filePath, hashFileContent(content), modifiedAt);
|
|
2432
|
+
}
|
|
2433
|
+
recordSyncedDelete(filePath) {
|
|
2434
|
+
this.clearContentEcho(filePath);
|
|
2435
|
+
this.metadata.recordDelete(filePath);
|
|
2436
|
+
}
|
|
2437
|
+
matchesAgreedContent(filePath, content) {
|
|
2438
|
+
return this.metadataFor(filePath)?.lastSyncedHash === hashFileContent(content);
|
|
2439
|
+
}
|
|
2440
|
+
armContentEcho(filePath, content) {
|
|
2441
|
+
const path = this.normalizePath(filePath);
|
|
2442
|
+
this.contentEchoes.set(path, hashFileContent(content));
|
|
2443
|
+
return {
|
|
2444
|
+
path,
|
|
2445
|
+
content
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
matchesContentEcho(filePath, content) {
|
|
2449
|
+
return this.contentEchoes.get(this.normalizePath(filePath)) === hashFileContent(content);
|
|
2450
|
+
}
|
|
2451
|
+
clearContentEcho(filePath) {
|
|
2452
|
+
this.contentEchoes.delete(this.normalizePath(filePath));
|
|
2453
|
+
}
|
|
2454
|
+
clearAllContentEchoes() {
|
|
2455
|
+
this.contentEchoes.clear();
|
|
2456
|
+
}
|
|
2457
|
+
isContentEcho(filePath, content) {
|
|
2458
|
+
return this.matchesAgreedContent(filePath, content) || this.matchesContentEcho(filePath, content);
|
|
2459
|
+
}
|
|
2460
|
+
commitWriteSuccess(prepared, modifiedAt) {
|
|
2461
|
+
this.recordSyncedContent(prepared.path, prepared.content, modifiedAt);
|
|
2462
|
+
}
|
|
2463
|
+
rollbackWriteFailure(prepared) {
|
|
2464
|
+
if (this.matchesContentEcho(prepared.path, prepared.content)) this.clearContentEcho(prepared.path);
|
|
2465
|
+
}
|
|
2466
|
+
armExpectedDeleteEcho(filePath) {
|
|
2467
|
+
const path = this.normalizePath(filePath);
|
|
2468
|
+
this.scheduler.cancel("expectedDeleteEchoExpiry", path);
|
|
2469
|
+
this.expectedDeleteEchoes.add(path);
|
|
2470
|
+
this.scheduler.after("expectedDeleteEchoExpiry", TIMINGS.expectedDeleteEchoExpiry, () => {
|
|
2471
|
+
this.expectedDeleteEchoes.delete(path);
|
|
2472
|
+
}, path);
|
|
2473
|
+
return { path };
|
|
2474
|
+
}
|
|
2475
|
+
matchesExpectedDeleteEcho(filePath) {
|
|
2476
|
+
return this.expectedDeleteEchoes.has(this.normalizePath(filePath));
|
|
2477
|
+
}
|
|
2478
|
+
clearExpectedDeleteEcho(filePath) {
|
|
2479
|
+
const path = this.normalizePath(filePath);
|
|
2480
|
+
this.scheduler.cancel("expectedDeleteEchoExpiry", path);
|
|
2481
|
+
this.expectedDeleteEchoes.delete(path);
|
|
2482
|
+
}
|
|
2483
|
+
commitDeleteSuccess(prepared) {
|
|
2484
|
+
this.clearContentEcho(prepared.path);
|
|
2485
|
+
this.recordSyncedDelete(prepared.path);
|
|
2486
|
+
}
|
|
2487
|
+
rollbackExpectedDeleteEcho(prepared) {
|
|
2488
|
+
this.clearExpectedDeleteEcho(prepared.path);
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
|
|
2492
|
+
//#endregion
|
|
2493
|
+
//#region src/runtime.ts
|
|
2494
|
+
function sameSession(a, b) {
|
|
2495
|
+
return a.connectionId === b.connectionId && a.promptId === b.promptId;
|
|
2496
|
+
}
|
|
2497
|
+
function createPromptSession(connectionId) {
|
|
2498
|
+
return {
|
|
2499
|
+
connectionId,
|
|
2500
|
+
promptId: randomUUID()
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
function conflictIsResolved(conflict) {
|
|
2504
|
+
return conflict.localContent === conflict.remoteContent;
|
|
2505
|
+
}
|
|
2506
|
+
function resolvedPromptConflict(conflict) {
|
|
2507
|
+
return {
|
|
2508
|
+
fileName: conflict.fileName,
|
|
2509
|
+
content: conflict.localContent,
|
|
2510
|
+
modifiedAt: conflict.remoteModifiedAt ?? conflict.localModifiedAt
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
function normalizeConflict(filePath, conflict) {
|
|
2514
|
+
return {
|
|
2515
|
+
...conflict,
|
|
2516
|
+
fileName: filePath(conflict.fileName)
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
1945
2519
|
/**
|
|
1946
|
-
*
|
|
2520
|
+
* SyncRuntime owns lifecycle truth.
|
|
2521
|
+
*
|
|
2522
|
+
* Search this file and `sync-memory.ts` first for race-sensitive state.
|
|
2523
|
+
* Lifecycle state lives here; file-level sync facts live on `memory`.
|
|
1947
2524
|
*/
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2525
|
+
var SyncRuntime = class {
|
|
2526
|
+
memory = new SyncMemory();
|
|
2527
|
+
pendingRenameConfirmations = /* @__PURE__ */ new Map();
|
|
2528
|
+
disconnectScheduler = createScheduler();
|
|
2529
|
+
activeDeletePrompt = null;
|
|
2530
|
+
activeConflictPrompt = null;
|
|
2531
|
+
pendingSyncCompletionEvent = null;
|
|
2532
|
+
installer = null;
|
|
2533
|
+
connectionSeq = 0;
|
|
2534
|
+
activeConnectionId = 0;
|
|
2535
|
+
isShowingDisconnect = false;
|
|
2536
|
+
hadRecentDisconnect = false;
|
|
2537
|
+
lastEmittedStatus = null;
|
|
2538
|
+
workspaceState = {
|
|
2539
|
+
projectDir: null,
|
|
2540
|
+
filesDir: null,
|
|
2541
|
+
projectDirCreated: false
|
|
2542
|
+
};
|
|
2543
|
+
get workspace() {
|
|
2544
|
+
return this.workspaceState;
|
|
2545
|
+
}
|
|
2546
|
+
get metadata() {
|
|
2547
|
+
return this.memory.metadata;
|
|
2548
|
+
}
|
|
2549
|
+
configureWorkspace(projectDir, projectDirCreated) {
|
|
2550
|
+
this.workspaceState.projectDir = projectDir;
|
|
2551
|
+
this.workspaceState.filesDir = path.join(projectDir, "files");
|
|
2552
|
+
this.workspaceState.projectDirCreated = projectDirCreated;
|
|
2553
|
+
}
|
|
2554
|
+
mintConnectionId() {
|
|
2555
|
+
this.connectionSeq += 1;
|
|
2556
|
+
this.activeConnectionId = this.connectionSeq;
|
|
2557
|
+
return this.activeConnectionId;
|
|
2558
|
+
}
|
|
2559
|
+
get connectionId() {
|
|
2560
|
+
return this.activeConnectionId;
|
|
2561
|
+
}
|
|
2562
|
+
noteEmittedSyncStatus(status) {
|
|
2563
|
+
this.lastEmittedStatus = status;
|
|
2564
|
+
}
|
|
2565
|
+
clearEmittedSyncStatus() {
|
|
2566
|
+
this.lastEmittedStatus = null;
|
|
2567
|
+
}
|
|
2568
|
+
get lastEmittedSyncStatus() {
|
|
2569
|
+
return this.lastEmittedStatus;
|
|
2570
|
+
}
|
|
2571
|
+
disconnectUi = {
|
|
2572
|
+
scheduleNotice: (callback) => {
|
|
2573
|
+
this.disconnectScheduler.cancel("disconnectNotice");
|
|
2574
|
+
this.hadRecentDisconnect = true;
|
|
2575
|
+
this.isShowingDisconnect = false;
|
|
2576
|
+
this.disconnectScheduler.after("disconnectNotice", TIMINGS.disconnectNotice, () => {
|
|
2577
|
+
this.isShowingDisconnect = true;
|
|
2578
|
+
callback();
|
|
2579
|
+
});
|
|
1973
2580
|
},
|
|
1974
|
-
|
|
1975
|
-
|
|
2581
|
+
cancelNotice: () => {
|
|
2582
|
+
this.disconnectScheduler.cancel("disconnectNotice");
|
|
1976
2583
|
},
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2584
|
+
didShowNotice: () => this.isShowingDisconnect,
|
|
2585
|
+
wasRecentlyDisconnected: () => this.hadRecentDisconnect,
|
|
2586
|
+
reset: () => {
|
|
2587
|
+
this.isShowingDisconnect = false;
|
|
2588
|
+
this.hadRecentDisconnect = false;
|
|
1981
2589
|
}
|
|
1982
2590
|
};
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2591
|
+
getPendingRename(newPath) {
|
|
2592
|
+
return this.pendingRenameConfirmations.get(normalizeCodeFilePathWithExtension(newPath));
|
|
2593
|
+
}
|
|
2594
|
+
registerPendingRename(newPath, value) {
|
|
2595
|
+
this.pendingRenameConfirmations.set(normalizeCodeFilePathWithExtension(newPath), value);
|
|
2596
|
+
}
|
|
2597
|
+
completePendingRename(newPath) {
|
|
2598
|
+
this.pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(newPath));
|
|
2599
|
+
}
|
|
2600
|
+
clearPendingRenames() {
|
|
2601
|
+
this.pendingRenameConfirmations.clear();
|
|
2602
|
+
}
|
|
2603
|
+
startDeletePrompt(fileNames) {
|
|
2604
|
+
const prompt = this.activeDeletePrompt ?? {
|
|
2605
|
+
session: createPromptSession(this.connectionId),
|
|
2606
|
+
fileNames: /* @__PURE__ */ new Set()
|
|
2607
|
+
};
|
|
2608
|
+
const newNames = [];
|
|
2609
|
+
for (const fileName of fileNames) {
|
|
2610
|
+
const normalized = this.memory.normalizePath(fileName);
|
|
2611
|
+
if (prompt.fileNames.has(normalized)) continue;
|
|
2612
|
+
prompt.fileNames.add(normalized);
|
|
2613
|
+
newNames.push(normalized);
|
|
2614
|
+
}
|
|
2615
|
+
this.activeDeletePrompt = prompt;
|
|
2616
|
+
return newNames.length > 0 ? {
|
|
2617
|
+
session: prompt.session,
|
|
2618
|
+
fileNames: newNames
|
|
2619
|
+
} : null;
|
|
2620
|
+
}
|
|
2621
|
+
hasActiveDeletePrompt(session) {
|
|
2622
|
+
return this.activeDeletePrompt !== null && sameSession(this.activeDeletePrompt.session, session);
|
|
2623
|
+
}
|
|
2624
|
+
getDeletePromptFileNames(session, fileNames) {
|
|
2625
|
+
const prompt = this.activeDeletePrompt;
|
|
2626
|
+
if (!prompt || !sameSession(prompt.session, session)) return null;
|
|
2627
|
+
const active = (fileNames.length > 0 ? fileNames.map((fileName) => this.memory.normalizePath(fileName)) : [...prompt.fileNames.values()]).filter((fileName) => prompt.fileNames.has(fileName));
|
|
2628
|
+
return active.length > 0 ? active : null;
|
|
2629
|
+
}
|
|
2630
|
+
clearDeletePromptFiles(session, fileNames) {
|
|
2631
|
+
const prompt = this.activeDeletePrompt;
|
|
2632
|
+
if (!prompt || !sameSession(prompt.session, session)) return false;
|
|
2633
|
+
const requested = fileNames.length > 0 ? fileNames : [...prompt.fileNames.values()];
|
|
2634
|
+
for (const fileName of requested) prompt.fileNames.delete(this.memory.normalizePath(fileName));
|
|
2635
|
+
if (prompt.fileNames.size === 0) this.activeDeletePrompt = null;
|
|
2636
|
+
return true;
|
|
2637
|
+
}
|
|
2638
|
+
isActiveDeletePromptPath(filePath) {
|
|
2639
|
+
return this.activeDeletePrompt?.fileNames.has(this.memory.normalizePath(filePath)) ?? false;
|
|
2640
|
+
}
|
|
2641
|
+
hasAnyActivePrompt() {
|
|
2642
|
+
return this.activeDeletePrompt !== null || this.activeConflictPrompt !== null;
|
|
2643
|
+
}
|
|
2644
|
+
deferSyncComplete(syncComplete) {
|
|
2645
|
+
this.pendingSyncCompletionEvent = this.pendingSyncCompletionEvent === null ? syncComplete : {
|
|
2646
|
+
totalCount: this.pendingSyncCompletionEvent.totalCount + syncComplete.totalCount,
|
|
2647
|
+
updatedCount: this.pendingSyncCompletionEvent.updatedCount + syncComplete.updatedCount,
|
|
2648
|
+
unchangedCount: this.pendingSyncCompletionEvent.unchangedCount + syncComplete.unchangedCount
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Claims the pending sync-complete event and clears it when ready to fire.
|
|
2653
|
+
* - `ready`: payload is returned and the slot is cleared.
|
|
2654
|
+
* - `blocked`: payload remains pending until prompts clear.
|
|
2655
|
+
* - `empty`: nothing was pending.
|
|
2656
|
+
*/
|
|
2657
|
+
claimPendingSyncComplete() {
|
|
2658
|
+
if (this.pendingSyncCompletionEvent === null) return { status: "empty" };
|
|
2659
|
+
if (this.hasAnyActivePrompt()) return { status: "blocked" };
|
|
2660
|
+
const syncComplete = this.pendingSyncCompletionEvent;
|
|
2661
|
+
this.pendingSyncCompletionEvent = null;
|
|
2662
|
+
return {
|
|
2663
|
+
status: "ready",
|
|
2664
|
+
payload: syncComplete
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
invalidateDeletePromptPath(filePath) {
|
|
2668
|
+
const prompt = this.activeDeletePrompt;
|
|
2669
|
+
const normalized = this.memory.normalizePath(filePath);
|
|
2670
|
+
if (!prompt || !prompt.fileNames.has(normalized)) return { changed: false };
|
|
2671
|
+
prompt.fileNames.delete(normalized);
|
|
2672
|
+
const cleared = prompt.fileNames.size === 0;
|
|
2673
|
+
if (cleared) this.activeDeletePrompt = null;
|
|
2674
|
+
return {
|
|
2675
|
+
changed: true,
|
|
2676
|
+
session: prompt.session,
|
|
2677
|
+
fileNames: [normalized],
|
|
2678
|
+
cleared
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
startOrUpdateConflictPrompt(conflicts) {
|
|
2682
|
+
if (conflicts.length === 0) return null;
|
|
2683
|
+
const prompt = this.activeConflictPrompt ?? {
|
|
2684
|
+
session: createPromptSession(this.connectionId),
|
|
2685
|
+
conflicts: /* @__PURE__ */ new Map()
|
|
2686
|
+
};
|
|
2687
|
+
for (const conflict of conflicts) {
|
|
2688
|
+
const normalized = normalizeConflict((filePath) => this.memory.normalizePath(filePath), conflict);
|
|
2689
|
+
if (conflictIsResolved(normalized)) prompt.conflicts.delete(normalized.fileName);
|
|
2690
|
+
else prompt.conflicts.set(normalized.fileName, normalized);
|
|
2691
|
+
}
|
|
2692
|
+
const nextConflicts = [...prompt.conflicts.values()];
|
|
2693
|
+
this.activeConflictPrompt = nextConflicts.length > 0 ? prompt : null;
|
|
2694
|
+
return nextConflicts.length > 0 ? {
|
|
2695
|
+
session: prompt.session,
|
|
2696
|
+
conflicts: nextConflicts
|
|
2697
|
+
} : null;
|
|
2698
|
+
}
|
|
2699
|
+
getActiveConflictPrompt() {
|
|
2700
|
+
const prompt = this.activeConflictPrompt;
|
|
2701
|
+
return prompt ? {
|
|
2702
|
+
session: prompt.session,
|
|
2703
|
+
conflicts: [...prompt.conflicts.values()]
|
|
2704
|
+
} : null;
|
|
2705
|
+
}
|
|
2706
|
+
getConflictPromptConflicts(session, fileNames) {
|
|
2707
|
+
const prompt = this.activeConflictPrompt;
|
|
2708
|
+
if (!prompt || !sameSession(prompt.session, session)) return null;
|
|
2709
|
+
const conflicts = (fileNames.length > 0 ? fileNames.map((fileName) => this.memory.normalizePath(fileName)) : [...prompt.conflicts.keys()]).map((fileName) => prompt.conflicts.get(fileName)).filter((conflict) => conflict !== void 0);
|
|
2710
|
+
return conflicts.length > 0 ? conflicts : null;
|
|
2711
|
+
}
|
|
2712
|
+
clearConflictPromptFiles(session, fileNames) {
|
|
2713
|
+
const prompt = this.activeConflictPrompt;
|
|
2714
|
+
if (!prompt || !sameSession(prompt.session, session)) return false;
|
|
2715
|
+
const requested = fileNames.length > 0 ? fileNames : [...prompt.conflicts.keys()];
|
|
2716
|
+
for (const fileName of requested) prompt.conflicts.delete(this.memory.normalizePath(fileName));
|
|
2717
|
+
if (prompt.conflicts.size === 0) this.activeConflictPrompt = null;
|
|
2718
|
+
return true;
|
|
2719
|
+
}
|
|
2720
|
+
isActiveConflictPath(filePath) {
|
|
2721
|
+
return this.activeConflictPrompt?.conflicts.has(this.memory.normalizePath(filePath)) ?? false;
|
|
2722
|
+
}
|
|
2723
|
+
updateActiveConflictLocal(filePath, content, modifiedAt) {
|
|
2724
|
+
const prompt = this.activeConflictPrompt;
|
|
2725
|
+
const key = this.memory.normalizePath(filePath);
|
|
2726
|
+
const conflict = prompt?.conflicts.get(key);
|
|
2727
|
+
if (!prompt || !conflict) return { changed: false };
|
|
2728
|
+
const next = {
|
|
2729
|
+
...conflict,
|
|
2730
|
+
fileName: key,
|
|
2731
|
+
localContent: content,
|
|
2732
|
+
localModifiedAt: modifiedAt
|
|
2733
|
+
};
|
|
2734
|
+
const resolved = conflictIsResolved(next) ? [resolvedPromptConflict(next)] : [];
|
|
2735
|
+
if (resolved.length > 0) prompt.conflicts.delete(key);
|
|
2736
|
+
else prompt.conflicts.set(key, next);
|
|
2737
|
+
const conflicts = [...prompt.conflicts.values()];
|
|
2738
|
+
const cleared = conflicts.length === 0;
|
|
2739
|
+
if (cleared) this.activeConflictPrompt = null;
|
|
2740
|
+
return {
|
|
2741
|
+
changed: true,
|
|
2742
|
+
session: prompt.session,
|
|
2743
|
+
conflicts,
|
|
2744
|
+
cleared,
|
|
2745
|
+
resolved
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
updateActiveConflictRemote(filePath, content, modifiedAt) {
|
|
2749
|
+
const prompt = this.activeConflictPrompt;
|
|
2750
|
+
const key = this.memory.normalizePath(filePath);
|
|
2751
|
+
const conflict = prompt?.conflicts.get(key);
|
|
2752
|
+
if (!prompt || !conflict) return { changed: false };
|
|
2753
|
+
const next = {
|
|
2754
|
+
...conflict,
|
|
2755
|
+
fileName: key,
|
|
2756
|
+
remoteContent: content,
|
|
2757
|
+
remoteModifiedAt: modifiedAt
|
|
2758
|
+
};
|
|
2759
|
+
const resolved = conflictIsResolved(next) ? [resolvedPromptConflict(next)] : [];
|
|
2760
|
+
if (resolved.length > 0) prompt.conflicts.delete(key);
|
|
2761
|
+
else prompt.conflicts.set(key, next);
|
|
2762
|
+
const conflicts = [...prompt.conflicts.values()];
|
|
2763
|
+
const cleared = conflicts.length === 0;
|
|
2764
|
+
if (cleared) this.activeConflictPrompt = null;
|
|
2765
|
+
return {
|
|
2766
|
+
changed: true,
|
|
2767
|
+
session: prompt.session,
|
|
2768
|
+
conflicts,
|
|
2769
|
+
cleared,
|
|
2770
|
+
resolved
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
resetPrompts() {
|
|
2774
|
+
this.activeDeletePrompt = null;
|
|
2775
|
+
this.activeConflictPrompt = null;
|
|
2776
|
+
this.pendingSyncCompletionEvent = null;
|
|
2777
|
+
}
|
|
2778
|
+
cleanupUserActions() {
|
|
2779
|
+
this.resetPrompts();
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
1990
2782
|
|
|
1991
2783
|
//#endregion
|
|
1992
2784
|
//#region src/utils/project.ts
|
|
@@ -1994,7 +2786,7 @@ function toPackageName(name) {
|
|
|
1994
2786
|
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1995
2787
|
}
|
|
1996
2788
|
function toDirectoryName(name) {
|
|
1997
|
-
return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2789
|
+
return name.replace(/[^a-zA-Z0-9 -]/g, "-").trim().replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1998
2790
|
}
|
|
1999
2791
|
async function getProjectHashFromCwd() {
|
|
2000
2792
|
try {
|
|
@@ -2034,7 +2826,7 @@ async function findOrCreateProjectDirectory(options) {
|
|
|
2034
2826
|
shortProjectHash: shortId,
|
|
2035
2827
|
framerProjectName: projectName
|
|
2036
2828
|
};
|
|
2037
|
-
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null,
|
|
2829
|
+
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 4));
|
|
2038
2830
|
return {
|
|
2039
2831
|
directory: projectDirectory,
|
|
2040
2832
|
created: true,
|
|
@@ -2083,6 +2875,14 @@ async function matchesProject(packageJsonPath, projectHash) {
|
|
|
2083
2875
|
|
|
2084
2876
|
//#endregion
|
|
2085
2877
|
//#region src/controller.ts
|
|
2878
|
+
function createEventQueue() {
|
|
2879
|
+
let tail = Promise.resolve();
|
|
2880
|
+
return { enqueue(fn) {
|
|
2881
|
+
const run = tail.then(() => fn());
|
|
2882
|
+
tail = run.catch(() => {});
|
|
2883
|
+
return run;
|
|
2884
|
+
} };
|
|
2885
|
+
}
|
|
2086
2886
|
/** Log helper */
|
|
2087
2887
|
function log(level, message) {
|
|
2088
2888
|
return {
|
|
@@ -2091,16 +2891,34 @@ function log(level, message) {
|
|
|
2091
2891
|
message
|
|
2092
2892
|
};
|
|
2093
2893
|
}
|
|
2894
|
+
function updatePendingConflictRemote(pendingConflicts, fileName, content, modifiedAt) {
|
|
2895
|
+
const normalized = normalizeCodeFilePathWithExtension(fileName);
|
|
2896
|
+
let changed = false;
|
|
2897
|
+
const conflicts = pendingConflicts.map((conflict) => {
|
|
2898
|
+
if (normalizeCodeFilePathWithExtension(conflict.fileName) !== normalized) return conflict;
|
|
2899
|
+
changed = true;
|
|
2900
|
+
return {
|
|
2901
|
+
...conflict,
|
|
2902
|
+
fileName: normalized,
|
|
2903
|
+
remoteContent: content,
|
|
2904
|
+
remoteModifiedAt: modifiedAt
|
|
2905
|
+
};
|
|
2906
|
+
});
|
|
2907
|
+
return {
|
|
2908
|
+
changed,
|
|
2909
|
+
conflicts
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2094
2912
|
/**
|
|
2095
|
-
*
|
|
2913
|
+
* State transition
|
|
2096
2914
|
* Takes current state + event, returns new state + effects to execute
|
|
2097
2915
|
*/
|
|
2098
|
-
function transition(state, event) {
|
|
2916
|
+
function transition(state, event, read = {}) {
|
|
2099
2917
|
const effects = [];
|
|
2100
2918
|
switch (event.type) {
|
|
2101
2919
|
case "HANDSHAKE":
|
|
2102
|
-
if (state.
|
|
2103
|
-
effects.push(log("warn", `Received HANDSHAKE in
|
|
2920
|
+
if (state.phase !== "disconnected") {
|
|
2921
|
+
effects.push(log("warn", `Received HANDSHAKE in phase=${state.phase}, ignoring`));
|
|
2104
2922
|
return {
|
|
2105
2923
|
state,
|
|
2106
2924
|
effects
|
|
@@ -2112,15 +2930,26 @@ function transition(state, event) {
|
|
|
2112
2930
|
}, { type: "LOAD_PERSISTED_STATE" }, {
|
|
2113
2931
|
type: "SEND_MESSAGE",
|
|
2114
2932
|
payload: { type: "request-files" }
|
|
2933
|
+
}, {
|
|
2934
|
+
type: "EMIT_SYNC_STATUS",
|
|
2935
|
+
status: "initial_sync"
|
|
2115
2936
|
});
|
|
2116
2937
|
return {
|
|
2117
2938
|
state: {
|
|
2118
|
-
|
|
2119
|
-
mode: "handshaking",
|
|
2939
|
+
phase: "handshaking",
|
|
2120
2940
|
socket: event.socket
|
|
2121
2941
|
},
|
|
2122
2942
|
effects
|
|
2123
2943
|
};
|
|
2944
|
+
case "RESEND_SYNC_STATUS":
|
|
2945
|
+
effects.push(log("debug", `Re-emitting sync-status=${event.status} for duplicate handshake`), {
|
|
2946
|
+
type: "EMIT_SYNC_STATUS",
|
|
2947
|
+
status: event.status
|
|
2948
|
+
});
|
|
2949
|
+
return {
|
|
2950
|
+
state,
|
|
2951
|
+
effects
|
|
2952
|
+
};
|
|
2124
2953
|
case "FILE_SYNCED_CONFIRMATION":
|
|
2125
2954
|
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
2126
2955
|
type: "UPDATE_FILE_METADATA",
|
|
@@ -2133,27 +2962,15 @@ function transition(state, event) {
|
|
|
2133
2962
|
};
|
|
2134
2963
|
case "DISCONNECT":
|
|
2135
2964
|
effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
|
|
2136
|
-
if (state.mode === "conflict_resolution") {
|
|
2137
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2138
|
-
return {
|
|
2139
|
-
state: {
|
|
2140
|
-
...rest,
|
|
2141
|
-
mode: "disconnected",
|
|
2142
|
-
socket: null
|
|
2143
|
-
},
|
|
2144
|
-
effects
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
2147
2965
|
return {
|
|
2148
2966
|
state: {
|
|
2149
|
-
|
|
2150
|
-
mode: "disconnected",
|
|
2967
|
+
phase: "disconnected",
|
|
2151
2968
|
socket: null
|
|
2152
2969
|
},
|
|
2153
2970
|
effects
|
|
2154
2971
|
};
|
|
2155
2972
|
case "REQUEST_FILES":
|
|
2156
|
-
if (state.
|
|
2973
|
+
if (state.phase === "disconnected") {
|
|
2157
2974
|
effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
|
|
2158
2975
|
return {
|
|
2159
2976
|
state,
|
|
@@ -2166,8 +2983,8 @@ function transition(state, event) {
|
|
|
2166
2983
|
effects
|
|
2167
2984
|
};
|
|
2168
2985
|
case "REMOTE_FILE_LIST":
|
|
2169
|
-
if (state.
|
|
2170
|
-
effects.push(log("warn", `Received REMOTE_FILE_LIST in
|
|
2986
|
+
if (state.phase !== "handshaking") {
|
|
2987
|
+
effects.push(log("warn", `Received REMOTE_FILE_LIST in phase=${state.phase}, ignoring`));
|
|
2171
2988
|
return {
|
|
2172
2989
|
state,
|
|
2173
2990
|
effects
|
|
@@ -2180,39 +2997,36 @@ function transition(state, event) {
|
|
|
2180
2997
|
});
|
|
2181
2998
|
return {
|
|
2182
2999
|
state: {
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
pendingRemoteChanges: event.files
|
|
3000
|
+
phase: "snapshot_processing",
|
|
3001
|
+
socket: state.socket
|
|
2186
3002
|
},
|
|
2187
3003
|
effects
|
|
2188
3004
|
};
|
|
2189
3005
|
case "CONFLICTS_DETECTED": {
|
|
2190
|
-
if (state.
|
|
2191
|
-
effects.push(log("warn", `Received CONFLICTS_DETECTED in
|
|
3006
|
+
if (state.phase !== "snapshot_processing") {
|
|
3007
|
+
effects.push(log("warn", `Received CONFLICTS_DETECTED in phase=${state.phase}, ignoring`));
|
|
2192
3008
|
return {
|
|
2193
3009
|
state,
|
|
2194
3010
|
effects
|
|
2195
3011
|
};
|
|
2196
3012
|
}
|
|
2197
|
-
const { conflicts, safeWrites, localOnly } = event;
|
|
3013
|
+
const { conflicts, safeWrites, localOnly, remoteTotal } = event;
|
|
2198
3014
|
if (safeWrites.length > 0) {
|
|
2199
3015
|
effects.push(log("debug", `Applying ${safeWrites.length} safe writes`));
|
|
2200
|
-
if (wasRecentlyDisconnected()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
|
|
3016
|
+
if (read.wasRecentlyDisconnected?.()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
|
|
2201
3017
|
effects.push({
|
|
2202
3018
|
type: "WRITE_FILES",
|
|
2203
3019
|
files: safeWrites,
|
|
2204
|
-
silent: true
|
|
3020
|
+
silent: true,
|
|
3021
|
+
echoPolicy: "authoritative"
|
|
2205
3022
|
});
|
|
2206
3023
|
}
|
|
2207
3024
|
if (localOnly.length > 0) {
|
|
2208
3025
|
effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
|
|
2209
3026
|
for (const file of localOnly) effects.push({
|
|
2210
|
-
type: "
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
fileName: file.name,
|
|
2214
|
-
content: file.content
|
|
2215
|
-
}
|
|
3027
|
+
type: "SEND_LOCAL_CHANGE",
|
|
3028
|
+
fileName: file.name,
|
|
3029
|
+
content: file.content
|
|
2216
3030
|
});
|
|
2217
3031
|
}
|
|
2218
3032
|
if (conflicts.length > 0) {
|
|
@@ -2222,14 +3036,13 @@ function transition(state, event) {
|
|
|
2222
3036
|
});
|
|
2223
3037
|
return {
|
|
2224
3038
|
state: {
|
|
2225
|
-
|
|
2226
|
-
|
|
3039
|
+
phase: "conflict_resolution",
|
|
3040
|
+
socket: state.socket,
|
|
2227
3041
|
pendingConflicts: conflicts
|
|
2228
3042
|
},
|
|
2229
3043
|
effects
|
|
2230
3044
|
};
|
|
2231
3045
|
}
|
|
2232
|
-
const remoteTotal = state.pendingRemoteChanges.length;
|
|
2233
3046
|
const totalCount = remoteTotal + localOnly.length;
|
|
2234
3047
|
const updatedCount = safeWrites.length + localOnly.length;
|
|
2235
3048
|
const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
|
|
@@ -2241,24 +3054,51 @@ function transition(state, event) {
|
|
|
2241
3054
|
});
|
|
2242
3055
|
return {
|
|
2243
3056
|
state: {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
pendingRemoteChanges: []
|
|
3057
|
+
phase: "watching",
|
|
3058
|
+
socket: state.socket
|
|
2247
3059
|
},
|
|
2248
3060
|
effects
|
|
2249
3061
|
};
|
|
2250
3062
|
}
|
|
2251
|
-
case "REMOTE_FILE_CHANGE":
|
|
2252
|
-
|
|
2253
|
-
|
|
3063
|
+
case "REMOTE_FILE_CHANGE":
|
|
3064
|
+
if (read.isActiveDeletePromptPath?.(event.file.name)) effects.push({
|
|
3065
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3066
|
+
fileName: event.file.name
|
|
3067
|
+
});
|
|
3068
|
+
if (read.isActiveConflictPath?.(event.file.name)) {
|
|
3069
|
+
effects.push(log("debug", `Updating active conflict from remote change: ${event.file.name}`), {
|
|
3070
|
+
type: "UPDATE_ACTIVE_CONFLICT_REMOTE",
|
|
3071
|
+
fileName: event.file.name,
|
|
3072
|
+
content: event.file.content,
|
|
3073
|
+
modifiedAt: event.file.modifiedAt
|
|
3074
|
+
});
|
|
3075
|
+
return {
|
|
3076
|
+
state,
|
|
3077
|
+
effects
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
if (state.phase === "conflict_resolution") {
|
|
3081
|
+
const next = updatePendingConflictRemote(state.pendingConflicts, event.file.name, event.file.content, event.file.modifiedAt);
|
|
3082
|
+
if (next.changed) {
|
|
3083
|
+
effects.push(log("debug", `Updating pending conflict from remote change: ${event.file.name}`));
|
|
3084
|
+
return {
|
|
3085
|
+
state: {
|
|
3086
|
+
...state,
|
|
3087
|
+
pendingConflicts: next.conflicts
|
|
3088
|
+
},
|
|
3089
|
+
effects
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
if (state.phase === "snapshot_processing" || state.phase === "handshaking") {
|
|
2254
3094
|
effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`));
|
|
2255
3095
|
return {
|
|
2256
3096
|
state,
|
|
2257
3097
|
effects
|
|
2258
3098
|
};
|
|
2259
3099
|
}
|
|
2260
|
-
if (
|
|
2261
|
-
effects.push(log("warn", `Rejected file change: ${event.file.name} (
|
|
3100
|
+
if (state.phase !== "watching" && state.phase !== "conflict_resolution") {
|
|
3101
|
+
effects.push(log("warn", `Rejected file change: ${event.file.name} (unknown-file)`));
|
|
2262
3102
|
return {
|
|
2263
3103
|
state,
|
|
2264
3104
|
effects
|
|
@@ -2267,15 +3107,43 @@ function transition(state, event) {
|
|
|
2267
3107
|
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
2268
3108
|
type: "WRITE_FILES",
|
|
2269
3109
|
files: [event.file],
|
|
2270
|
-
|
|
3110
|
+
echoPolicy: "skip-expected-echoes"
|
|
2271
3111
|
});
|
|
2272
3112
|
return {
|
|
2273
3113
|
state,
|
|
2274
3114
|
effects
|
|
2275
3115
|
};
|
|
2276
|
-
}
|
|
2277
3116
|
case "REMOTE_FILE_DELETE":
|
|
2278
|
-
if (
|
|
3117
|
+
if (read.isActiveDeletePromptPath?.(event.fileName)) effects.push({
|
|
3118
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3119
|
+
fileName: event.fileName
|
|
3120
|
+
});
|
|
3121
|
+
if (read.isActiveConflictPath?.(event.fileName)) {
|
|
3122
|
+
effects.push(log("debug", `Updating active conflict from remote delete: ${event.fileName}`), {
|
|
3123
|
+
type: "UPDATE_ACTIVE_CONFLICT_REMOTE",
|
|
3124
|
+
fileName: event.fileName,
|
|
3125
|
+
content: null,
|
|
3126
|
+
modifiedAt: Date.now()
|
|
3127
|
+
});
|
|
3128
|
+
return {
|
|
3129
|
+
state,
|
|
3130
|
+
effects
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
if (state.phase === "conflict_resolution") {
|
|
3134
|
+
const next = updatePendingConflictRemote(state.pendingConflicts, event.fileName, null, Date.now());
|
|
3135
|
+
if (next.changed) {
|
|
3136
|
+
effects.push(log("debug", `Updating pending conflict from remote delete: ${event.fileName}`));
|
|
3137
|
+
return {
|
|
3138
|
+
state: {
|
|
3139
|
+
...state,
|
|
3140
|
+
pendingConflicts: next.conflicts
|
|
3141
|
+
},
|
|
3142
|
+
effects
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
if (state.phase === "disconnected") {
|
|
2279
3147
|
effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
|
|
2280
3148
|
return {
|
|
2281
3149
|
state,
|
|
@@ -2290,88 +3158,46 @@ function transition(state, event) {
|
|
|
2290
3158
|
state,
|
|
2291
3159
|
effects
|
|
2292
3160
|
};
|
|
2293
|
-
case "
|
|
2294
|
-
effects.push(
|
|
2295
|
-
type: "
|
|
2296
|
-
|
|
2297
|
-
|
|
3161
|
+
case "DELETE_CONFIRMED":
|
|
3162
|
+
effects.push({
|
|
3163
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3164
|
+
session: event.session,
|
|
3165
|
+
confirmedFileNames: event.fileNames,
|
|
3166
|
+
cancelledFiles: []
|
|
3167
|
+
});
|
|
2298
3168
|
return {
|
|
2299
3169
|
state,
|
|
2300
3170
|
effects
|
|
2301
3171
|
};
|
|
2302
|
-
case "
|
|
2303
|
-
effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
|
|
3172
|
+
case "DELETE_CANCELLED":
|
|
2304
3173
|
effects.push({
|
|
2305
|
-
type: "
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
modifiedAt: Date.now()
|
|
2310
|
-
}]
|
|
3174
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3175
|
+
session: event.session,
|
|
3176
|
+
confirmedFileNames: [],
|
|
3177
|
+
cancelledFiles: event.files
|
|
2311
3178
|
});
|
|
2312
3179
|
return {
|
|
2313
3180
|
state,
|
|
2314
3181
|
effects
|
|
2315
3182
|
};
|
|
2316
|
-
case "CONFLICTS_RESOLVED":
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
};
|
|
2323
|
-
}
|
|
2324
|
-
if (event.resolution === "remote") {
|
|
2325
|
-
for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
|
|
2326
|
-
type: "DELETE_LOCAL_FILES",
|
|
2327
|
-
names: [conflict.fileName]
|
|
2328
|
-
});
|
|
2329
|
-
else effects.push({
|
|
2330
|
-
type: "WRITE_FILES",
|
|
2331
|
-
files: [{
|
|
2332
|
-
name: conflict.fileName,
|
|
2333
|
-
content: conflict.remoteContent,
|
|
2334
|
-
modifiedAt: conflict.remoteModifiedAt
|
|
2335
|
-
}],
|
|
2336
|
-
silent: true
|
|
2337
|
-
});
|
|
2338
|
-
effects.push(log("success", "Keeping Framer changes"));
|
|
2339
|
-
} else {
|
|
2340
|
-
const localDeletes = [];
|
|
2341
|
-
for (const conflict of state.pendingConflicts) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
|
|
2342
|
-
else effects.push({
|
|
2343
|
-
type: "SEND_MESSAGE",
|
|
2344
|
-
payload: {
|
|
2345
|
-
type: "file-change",
|
|
2346
|
-
fileName: conflict.fileName,
|
|
2347
|
-
content: conflict.localContent
|
|
2348
|
-
}
|
|
2349
|
-
});
|
|
2350
|
-
if (localDeletes.length > 0) effects.push({
|
|
2351
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2352
|
-
fileNames: localDeletes
|
|
2353
|
-
});
|
|
2354
|
-
effects.push(log("success", "Keeping local changes"));
|
|
2355
|
-
}
|
|
2356
|
-
effects.push({ type: "PERSIST_STATE" }, {
|
|
2357
|
-
type: "SYNC_COMPLETE",
|
|
2358
|
-
totalCount: state.pendingConflicts.length,
|
|
2359
|
-
updatedCount: state.pendingConflicts.length,
|
|
2360
|
-
unchangedCount: 0
|
|
3183
|
+
case "CONFLICTS_RESOLVED":
|
|
3184
|
+
effects.push({
|
|
3185
|
+
type: "RESOLVE_CONFLICT_PROMPT",
|
|
3186
|
+
session: event.session,
|
|
3187
|
+
resolution: event.resolution,
|
|
3188
|
+
fileNames: event.fileNames
|
|
2361
3189
|
});
|
|
2362
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2363
3190
|
return {
|
|
2364
|
-
state: {
|
|
2365
|
-
|
|
2366
|
-
|
|
3191
|
+
state: state.phase === "disconnected" ? state : {
|
|
3192
|
+
phase: "watching",
|
|
3193
|
+
socket: state.socket
|
|
2367
3194
|
},
|
|
2368
3195
|
effects
|
|
2369
3196
|
};
|
|
2370
|
-
}
|
|
2371
3197
|
case "WATCHER_EVENT": {
|
|
2372
3198
|
const { kind, relativePath, content } = event.event;
|
|
2373
|
-
if (state.
|
|
2374
|
-
effects.push(log("debug", `Ignoring watcher event in
|
|
3199
|
+
if (state.phase !== "watching") {
|
|
3200
|
+
effects.push(log("debug", `Ignoring watcher event in phase=${state.phase}: ${kind} ${relativePath}`));
|
|
2375
3201
|
return {
|
|
2376
3202
|
state,
|
|
2377
3203
|
effects
|
|
@@ -2387,46 +3213,74 @@ function transition(state, event) {
|
|
|
2387
3213
|
effects
|
|
2388
3214
|
};
|
|
2389
3215
|
}
|
|
2390
|
-
effects.push({
|
|
3216
|
+
if (read.isActiveDeletePromptPath?.(relativePath)) effects.push({
|
|
3217
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3218
|
+
fileName: relativePath
|
|
3219
|
+
});
|
|
3220
|
+
if (read.isActiveConflictPath?.(relativePath)) effects.push(log("debug", `Updating active conflict from local change: ${relativePath}`), {
|
|
3221
|
+
type: "UPDATE_ACTIVE_CONFLICT_LOCAL",
|
|
3222
|
+
fileName: relativePath,
|
|
3223
|
+
content,
|
|
3224
|
+
modifiedAt: Date.now()
|
|
3225
|
+
});
|
|
3226
|
+
else effects.push({
|
|
2391
3227
|
type: "SEND_LOCAL_CHANGE",
|
|
2392
3228
|
fileName: relativePath,
|
|
2393
3229
|
content
|
|
2394
3230
|
});
|
|
2395
3231
|
break;
|
|
2396
3232
|
case "delete":
|
|
2397
|
-
effects.push(log("debug", `
|
|
3233
|
+
if (read.isActiveConflictPath?.(relativePath)) effects.push(log("debug", `Updating active conflict from local delete: ${relativePath}`), {
|
|
3234
|
+
type: "UPDATE_ACTIVE_CONFLICT_LOCAL",
|
|
3235
|
+
fileName: relativePath,
|
|
3236
|
+
content: null,
|
|
3237
|
+
modifiedAt: Date.now()
|
|
3238
|
+
});
|
|
3239
|
+
else effects.push(log("debug", `Local delete detected: ${relativePath}`), {
|
|
2398
3240
|
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2399
3241
|
fileNames: [relativePath]
|
|
2400
3242
|
});
|
|
2401
3243
|
break;
|
|
3244
|
+
case "rename":
|
|
3245
|
+
if (content === void 0 || !event.event.oldRelativePath) {
|
|
3246
|
+
effects.push(log("warn", `Rename event missing data: ${relativePath}`));
|
|
3247
|
+
return {
|
|
3248
|
+
state,
|
|
3249
|
+
effects
|
|
3250
|
+
};
|
|
3251
|
+
}
|
|
3252
|
+
if (read.isActiveConflictPath?.(relativePath) || read.isActiveConflictPath?.(event.event.oldRelativePath)) effects.push(log("debug", `Ignoring rename touching active conflict: ${event.event.oldRelativePath} -> ${relativePath}`));
|
|
3253
|
+
else effects.push(log("debug", `Local rename detected: ${event.event.oldRelativePath} → ${relativePath}`), {
|
|
3254
|
+
type: "SEND_FILE_RENAME",
|
|
3255
|
+
oldFileName: event.event.oldRelativePath,
|
|
3256
|
+
newFileName: relativePath,
|
|
3257
|
+
content
|
|
3258
|
+
});
|
|
3259
|
+
break;
|
|
2402
3260
|
}
|
|
2403
3261
|
return {
|
|
2404
3262
|
state,
|
|
2405
3263
|
effects
|
|
2406
3264
|
};
|
|
2407
3265
|
}
|
|
2408
|
-
case "
|
|
2409
|
-
if (state.
|
|
2410
|
-
effects.push(log("warn", `Received
|
|
3266
|
+
case "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS": {
|
|
3267
|
+
if (state.phase !== "conflict_resolution") {
|
|
3268
|
+
effects.push(log("warn", `Received RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS in phase=${state.phase}, ignoring`));
|
|
2411
3269
|
return {
|
|
2412
3270
|
state,
|
|
2413
3271
|
effects
|
|
2414
3272
|
};
|
|
2415
3273
|
}
|
|
2416
3274
|
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
|
|
3275
|
+
const localDeleteConflicts = [];
|
|
2417
3276
|
if (autoResolvedLocal.length > 0) {
|
|
2418
3277
|
effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
|
|
2419
|
-
const
|
|
2420
|
-
for (const conflict of autoResolvedLocal) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
|
|
3278
|
+
for (const conflict of autoResolvedLocal) if (conflict.localContent === null) localDeleteConflicts.push(conflict);
|
|
2421
3279
|
else effects.push({
|
|
2422
3280
|
type: "SEND_LOCAL_CHANGE",
|
|
2423
3281
|
fileName: conflict.fileName,
|
|
2424
3282
|
content: conflict.localContent
|
|
2425
3283
|
});
|
|
2426
|
-
if (localDeletes.length > 0) effects.push({
|
|
2427
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2428
|
-
fileNames: localDeletes
|
|
2429
|
-
});
|
|
2430
3284
|
}
|
|
2431
3285
|
if (autoResolvedRemote.length > 0) {
|
|
2432
3286
|
effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
|
|
@@ -2441,22 +3295,28 @@ function transition(state, event) {
|
|
|
2441
3295
|
content: conflict.remoteContent,
|
|
2442
3296
|
modifiedAt: conflict.remoteModifiedAt ?? Date.now()
|
|
2443
3297
|
}],
|
|
3298
|
+
echoPolicy: "authoritative",
|
|
2444
3299
|
silent: true
|
|
2445
3300
|
});
|
|
2446
3301
|
}
|
|
2447
|
-
|
|
2448
|
-
|
|
3302
|
+
const conflictsForPrompt = remainingConflicts.length > 0 ? [...remainingConflicts, ...localDeleteConflicts] : remainingConflicts;
|
|
3303
|
+
if (conflictsForPrompt.length > 0) {
|
|
3304
|
+
effects.push(log("warn", `${pluralize(conflictsForPrompt.length, "conflict")} require resolution`), {
|
|
2449
3305
|
type: "REQUEST_CONFLICT_DECISIONS",
|
|
2450
|
-
conflicts:
|
|
3306
|
+
conflicts: conflictsForPrompt
|
|
2451
3307
|
});
|
|
2452
3308
|
return {
|
|
2453
3309
|
state: {
|
|
2454
|
-
|
|
2455
|
-
|
|
3310
|
+
phase: "watching",
|
|
3311
|
+
socket: state.socket
|
|
2456
3312
|
},
|
|
2457
3313
|
effects
|
|
2458
3314
|
};
|
|
2459
3315
|
}
|
|
3316
|
+
if (localDeleteConflicts.length > 0) effects.push({
|
|
3317
|
+
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
3318
|
+
fileNames: localDeleteConflicts.map((conflict) => conflict.fileName)
|
|
3319
|
+
});
|
|
2460
3320
|
const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
|
|
2461
3321
|
effects.push({ type: "PERSIST_STATE" }, {
|
|
2462
3322
|
type: "SYNC_COMPLETE",
|
|
@@ -2464,12 +3324,10 @@ function transition(state, event) {
|
|
|
2464
3324
|
updatedCount: resolvedCount,
|
|
2465
3325
|
unchangedCount: 0
|
|
2466
3326
|
});
|
|
2467
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2468
3327
|
return {
|
|
2469
3328
|
state: {
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
pendingRemoteChanges: []
|
|
3329
|
+
phase: "watching",
|
|
3330
|
+
socket: state.socket
|
|
2473
3331
|
},
|
|
2474
3332
|
effects
|
|
2475
3333
|
};
|
|
@@ -2482,234 +3340,484 @@ function transition(state, event) {
|
|
|
2482
3340
|
};
|
|
2483
3341
|
}
|
|
2484
3342
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
3343
|
+
function emitLog(entry) {
|
|
3344
|
+
({
|
|
3345
|
+
info,
|
|
3346
|
+
debug,
|
|
3347
|
+
warn,
|
|
3348
|
+
success,
|
|
3349
|
+
status
|
|
3350
|
+
})[entry.level](entry.message);
|
|
3351
|
+
}
|
|
3352
|
+
function syncCompleteStatusMessage(config) {
|
|
3353
|
+
return config.once ? "Sync complete, exiting..." : "Watching for changes...";
|
|
3354
|
+
}
|
|
3355
|
+
function syncCompleteSuccessMessage(runtime, effect) {
|
|
3356
|
+
const relative = runtime.workspace.projectDir ? path.relative(process.cwd(), runtime.workspace.projectDir) : null;
|
|
3357
|
+
const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
|
|
3358
|
+
if (effect.totalCount === 0 && relativeDirectory) return runtime.workspace.projectDirCreated ? `Created ${relativeDirectory} folder` : `Syncing to ${relativeDirectory} folder`;
|
|
3359
|
+
if (relativeDirectory && runtime.workspace.projectDirCreated) return `Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`;
|
|
3360
|
+
if (relativeDirectory) return `Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`;
|
|
3361
|
+
return `Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`;
|
|
3362
|
+
}
|
|
3363
|
+
function sendFailureLabel(message) {
|
|
3364
|
+
return message.type === "file-change" ? message.fileName : message.type;
|
|
3365
|
+
}
|
|
3366
|
+
async function sendToPlugin(socket, message) {
|
|
3367
|
+
if (!socket) return false;
|
|
3368
|
+
try {
|
|
3369
|
+
return await sendMessage(socket, message);
|
|
3370
|
+
} catch {
|
|
3371
|
+
warn(`Failed to push ${sendFailureLabel(message)}`);
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
async function writeFiles(files, ctx, options) {
|
|
3376
|
+
const { runtime } = ctx;
|
|
3377
|
+
if (!runtime.workspace.filesDir) return;
|
|
3378
|
+
const filesToWrite = options.echoPolicy === "skip-expected-echoes" ? filterEchoedFiles(files, runtime.memory) : files;
|
|
3379
|
+
if (options.echoPolicy === "skip-expected-echoes" && filesToWrite.length !== files.length) debug(`Skipped ${pluralize(files.length - filesToWrite.length, "echoed change")}`);
|
|
3380
|
+
const results = await writeRemoteFiles(filesToWrite, runtime.workspace.filesDir, runtime.memory);
|
|
3381
|
+
for (const result of results) {
|
|
3382
|
+
if (!result.ok) continue;
|
|
3383
|
+
if (!options.silent) fileDown(result.path);
|
|
3384
|
+
runtime.memory.recordSyncedContent(result.path, result.file.content, result.file.modifiedAt ?? Date.now());
|
|
3385
|
+
runtime.installer?.process(result.path, result.file.content);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
async function deleteFiles(fileNames, ctx) {
|
|
3389
|
+
const { runtime } = ctx;
|
|
3390
|
+
if (!runtime.workspace.filesDir) return;
|
|
3391
|
+
for (const fileName of fileNames) {
|
|
3392
|
+
const result = await deleteLocalFile(fileName, runtime.workspace.filesDir, runtime.memory);
|
|
3393
|
+
if (!result.ok) continue;
|
|
3394
|
+
fileDelete(result.fileName);
|
|
3395
|
+
runtime.memory.recordSyncedDelete(result.fileName);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
async function sendLocalChange(fileName, content, ctx) {
|
|
3399
|
+
const { runtime, syncState } = ctx;
|
|
3400
|
+
if (runtime.metadata.get(fileName)?.lastSyncedHash === hashFileContent(content)) {
|
|
3401
|
+
debug(`Skipping local change for ${fileName}: matches last synced content`);
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
if (runtime.memory.matchesContentEcho(fileName, content)) return;
|
|
3405
|
+
debug(`Local change detected: ${fileName}`);
|
|
3406
|
+
if (!await sendToPlugin(syncState.socket, {
|
|
3407
|
+
type: "file-change",
|
|
3408
|
+
fileName,
|
|
3409
|
+
content
|
|
3410
|
+
})) return;
|
|
3411
|
+
runtime.memory.armContentEcho(fileName, content);
|
|
3412
|
+
fileUp(fileName);
|
|
3413
|
+
runtime.installer?.process(fileName, content);
|
|
3414
|
+
}
|
|
3415
|
+
async function sendFileDelete(fileNames, ctx) {
|
|
3416
|
+
if (fileNames.length === 0) return;
|
|
3417
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3418
|
+
type: "file-delete",
|
|
3419
|
+
mode: "auto",
|
|
3420
|
+
fileNames
|
|
3421
|
+
})) return;
|
|
3422
|
+
for (const fileName of fileNames) ctx.runtime.memory.recordSyncedDelete(fileName);
|
|
3423
|
+
}
|
|
3424
|
+
async function sendFileRename(effect, ctx) {
|
|
3425
|
+
const { runtime, syncState } = ctx;
|
|
3426
|
+
const newFileName = normalizeCodeFilePathWithExtension(effect.newFileName);
|
|
3427
|
+
if (runtime.memory.matchesContentEcho(newFileName, effect.content) && runtime.memory.matchesExpectedDeleteEcho(effect.oldFileName)) {
|
|
3428
|
+
debug(`Skipping echoed rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3429
|
+
runtime.memory.clearContentEcho(newFileName);
|
|
3430
|
+
runtime.memory.clearExpectedDeleteEcho(effect.oldFileName);
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
3433
|
+
if (!syncState.socket) {
|
|
3434
|
+
warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
if (await sendToPlugin(syncState.socket, {
|
|
3438
|
+
type: "file-rename",
|
|
3439
|
+
oldFileName: effect.oldFileName,
|
|
3440
|
+
newFileName,
|
|
3441
|
+
content: effect.content
|
|
3442
|
+
})) runtime.registerPendingRename(newFileName, {
|
|
3443
|
+
oldFileName: effect.oldFileName,
|
|
3444
|
+
content: effect.content
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
async function startDeletePrompt(fileNames, ctx) {
|
|
3448
|
+
const prompt = ctx.runtime.startDeletePrompt(fileNames);
|
|
3449
|
+
if (!prompt) return;
|
|
3450
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3451
|
+
type: "file-delete",
|
|
3452
|
+
mode: "confirm",
|
|
3453
|
+
fileNames: prompt.fileNames,
|
|
3454
|
+
session: prompt.session
|
|
3455
|
+
})) {
|
|
3456
|
+
ctx.runtime.clearDeletePromptFiles(prompt.session, prompt.fileNames);
|
|
3457
|
+
warn(`Failed to request delete confirmation for ${prompt.fileNames.join(", ")}`);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
async function startConflictPrompt(conflicts, ctx) {
|
|
3461
|
+
const prompt = ctx.runtime.startOrUpdateConflictPrompt(conflicts);
|
|
3462
|
+
if (!prompt) return;
|
|
3463
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3464
|
+
type: "conflicts-detected",
|
|
3465
|
+
conflicts: prompt.conflicts,
|
|
3466
|
+
session: prompt.session
|
|
3467
|
+
})) {
|
|
3468
|
+
ctx.runtime.clearConflictPromptFiles(prompt.session, prompt.conflicts.map((conflict) => conflict.fileName));
|
|
3469
|
+
warn("Failed to send conflict prompt");
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
async function applyConflictChange(change, ctx) {
|
|
3473
|
+
if (!change.changed) return;
|
|
3474
|
+
for (const resolved of change.resolved) if (resolved.content === null) ctx.runtime.memory.recordSyncedDelete(resolved.fileName);
|
|
3475
|
+
else ctx.runtime.memory.recordSyncedContent(resolved.fileName, resolved.content, resolved.modifiedAt ?? Date.now());
|
|
3476
|
+
if (ctx.syncState.socket) await sendToPlugin(ctx.syncState.socket, change.cleared ? {
|
|
3477
|
+
type: "conflicts-cleared",
|
|
3478
|
+
session: change.session
|
|
3479
|
+
} : {
|
|
3480
|
+
type: "conflicts-detected",
|
|
3481
|
+
conflicts: change.conflicts,
|
|
3482
|
+
session: change.session
|
|
3483
|
+
});
|
|
3484
|
+
if (change.resolved.length > 0) await ctx.runtime.metadata.flush();
|
|
3485
|
+
if (change.cleared && ctx.runtime.lastEmittedSyncStatus !== "ready") {
|
|
3486
|
+
if (await flushPendingSyncComplete(ctx) !== "empty") return;
|
|
3487
|
+
await applySyncComplete({
|
|
3488
|
+
type: "SYNC_COMPLETE",
|
|
3489
|
+
totalCount: change.resolved.length,
|
|
3490
|
+
updatedCount: change.resolved.length,
|
|
3491
|
+
unchangedCount: 0
|
|
3492
|
+
}, ctx);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
async function applySyncComplete(effect, ctx) {
|
|
3496
|
+
const { config, runtime, syncState, shutdown } = ctx;
|
|
3497
|
+
if (runtime.hasAnyActivePrompt()) {
|
|
3498
|
+
runtime.deferSyncComplete({
|
|
3499
|
+
totalCount: effect.totalCount,
|
|
3500
|
+
updatedCount: effect.updatedCount,
|
|
3501
|
+
unchangedCount: effect.unchangedCount
|
|
3502
|
+
});
|
|
3503
|
+
debug("Deferring sync completion until active prompts resolve");
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
const wasDisconnected = runtime.disconnectUi.wasRecentlyDisconnected();
|
|
3507
|
+
let shouldShutdown = !!config.once;
|
|
3508
|
+
let shouldTryGitInit = false;
|
|
3509
|
+
if (wasDisconnected) {
|
|
3510
|
+
const didShow = runtime.disconnectUi.didShowNotice();
|
|
3511
|
+
shouldShutdown = didShow && !!config.once;
|
|
3512
|
+
if (didShow) {
|
|
3513
|
+
success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3514
|
+
status(syncCompleteStatusMessage(config));
|
|
3515
|
+
}
|
|
3516
|
+
} else {
|
|
3517
|
+
const message = syncCompleteSuccessMessage(runtime, effect);
|
|
3518
|
+
if (message) success(message);
|
|
3519
|
+
status(syncCompleteStatusMessage(config));
|
|
3520
|
+
shouldTryGitInit = !!(runtime.workspace.projectDirCreated && runtime.workspace.projectDir);
|
|
3521
|
+
}
|
|
3522
|
+
await sendToPlugin(syncState.socket, {
|
|
3523
|
+
type: "sync-status",
|
|
3524
|
+
status: "ready"
|
|
3525
|
+
});
|
|
3526
|
+
runtime.noteEmittedSyncStatus("ready");
|
|
3527
|
+
if (wasDisconnected) runtime.disconnectUi.reset();
|
|
3528
|
+
if (shouldTryGitInit && runtime.workspace.projectDir) tryGitInit(runtime.workspace.projectDir);
|
|
3529
|
+
if (shouldShutdown) await shutdown();
|
|
3530
|
+
}
|
|
3531
|
+
async function flushPendingSyncComplete(ctx) {
|
|
3532
|
+
const result = ctx.runtime.claimPendingSyncComplete();
|
|
3533
|
+
if (result.status === "ready") await applySyncComplete({
|
|
3534
|
+
type: "SYNC_COMPLETE",
|
|
3535
|
+
...result.payload
|
|
3536
|
+
}, ctx);
|
|
3537
|
+
return result.status;
|
|
3538
|
+
}
|
|
3539
|
+
async function applyEffect(effect, ctx) {
|
|
3540
|
+
const { config, runtime, syncState } = ctx;
|
|
2491
3541
|
switch (effect.type) {
|
|
2492
|
-
case "INIT_WORKSPACE":
|
|
2493
|
-
if (
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
debug(`Files directory: ${config.filesDir}`);
|
|
2505
|
-
await fs.mkdir(config.filesDir, { recursive: true });
|
|
2506
|
-
}
|
|
3542
|
+
case "INIT_WORKSPACE": {
|
|
3543
|
+
if (runtime.workspace.projectDir) return [];
|
|
3544
|
+
const projectName = config.explicitName ?? effect.projectInfo.projectName;
|
|
3545
|
+
const directoryInfo = await findOrCreateProjectDirectory({
|
|
3546
|
+
projectHash: config.projectHash,
|
|
3547
|
+
projectName,
|
|
3548
|
+
explicitDirectory: config.explicitDirectory
|
|
3549
|
+
});
|
|
3550
|
+
runtime.configureWorkspace(directoryInfo.directory, directoryInfo.created);
|
|
3551
|
+
if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
|
|
3552
|
+
debug(`Files directory: ${runtime.workspace.filesDir}`);
|
|
3553
|
+
await fs.mkdir(runtime.workspace.filesDir, { recursive: true });
|
|
2507
3554
|
return [];
|
|
3555
|
+
}
|
|
2508
3556
|
case "LOAD_PERSISTED_STATE":
|
|
2509
|
-
if (
|
|
2510
|
-
await
|
|
2511
|
-
debug(`Loaded persisted metadata for ${pluralize(
|
|
3557
|
+
if (runtime.workspace.projectDir) {
|
|
3558
|
+
await runtime.metadata.initialize(runtime.workspace.projectDir);
|
|
3559
|
+
debug(`Loaded persisted metadata for ${pluralize(runtime.metadata.size(), "file")}`);
|
|
2512
3560
|
}
|
|
2513
3561
|
return [];
|
|
2514
|
-
case "LIST_LOCAL_FILES":
|
|
2515
|
-
if (
|
|
2516
|
-
const files = await listFiles(config.filesDir);
|
|
2517
|
-
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
3562
|
+
case "LIST_LOCAL_FILES":
|
|
3563
|
+
if (runtime.workspace.filesDir) await sendToPlugin(syncState.socket, {
|
|
2518
3564
|
type: "file-list",
|
|
2519
|
-
files
|
|
3565
|
+
files: await listFiles(runtime.workspace.filesDir)
|
|
2520
3566
|
});
|
|
2521
3567
|
return [];
|
|
2522
|
-
}
|
|
2523
3568
|
case "DETECT_CONFLICTS": {
|
|
2524
|
-
if (!
|
|
2525
|
-
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles,
|
|
2526
|
-
for (const file of unchanged)
|
|
3569
|
+
if (!runtime.workspace.filesDir) return [];
|
|
3570
|
+
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles, runtime.workspace.filesDir, { persistedState: runtime.metadata.getPersistedState() });
|
|
3571
|
+
for (const file of unchanged) runtime.memory.recordSyncedContent(file.name, file.content, file.modifiedAt ?? Date.now());
|
|
2527
3572
|
return [{
|
|
2528
3573
|
type: "CONFLICTS_DETECTED",
|
|
2529
3574
|
conflicts,
|
|
2530
3575
|
safeWrites: writes,
|
|
2531
|
-
localOnly
|
|
3576
|
+
localOnly,
|
|
3577
|
+
remoteTotal: effect.remoteFiles.length
|
|
2532
3578
|
}];
|
|
2533
3579
|
}
|
|
2534
3580
|
case "SEND_MESSAGE":
|
|
2535
|
-
if (
|
|
2536
|
-
|
|
2537
|
-
|
|
3581
|
+
if (effect.payload.type === "file-change") await sendLocalChange(effect.payload.fileName, effect.payload.content, ctx);
|
|
3582
|
+
else await sendToPlugin(syncState.socket, effect.payload);
|
|
3583
|
+
return [];
|
|
3584
|
+
case "EMIT_SYNC_STATUS":
|
|
3585
|
+
await sendToPlugin(syncState.socket, {
|
|
3586
|
+
type: "sync-status",
|
|
3587
|
+
status: effect.status
|
|
3588
|
+
});
|
|
3589
|
+
runtime.noteEmittedSyncStatus(effect.status);
|
|
2538
3590
|
return [];
|
|
2539
3591
|
case "WRITE_FILES":
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
await writeRemoteFiles(filesToWrite, config.filesDir, hashTracker, installer ?? void 0);
|
|
2545
|
-
for (const file of filesToWrite) {
|
|
2546
|
-
if (!effect.silent) fileDown(file.name);
|
|
2547
|
-
const remoteTimestamp = file.modifiedAt ?? Date.now();
|
|
2548
|
-
fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
3592
|
+
await writeFiles(effect.files, ctx, {
|
|
3593
|
+
silent: effect.silent,
|
|
3594
|
+
echoPolicy: effect.echoPolicy
|
|
3595
|
+
});
|
|
2551
3596
|
return [];
|
|
2552
3597
|
case "DELETE_LOCAL_FILES":
|
|
2553
|
-
|
|
2554
|
-
await deleteLocalFile(fileName, config.filesDir, hashTracker);
|
|
2555
|
-
fileDelete(fileName);
|
|
2556
|
-
fileMetadataCache.recordDelete(fileName);
|
|
2557
|
-
}
|
|
3598
|
+
await deleteFiles(effect.names, ctx);
|
|
2558
3599
|
return [];
|
|
2559
3600
|
case "REQUEST_CONFLICT_DECISIONS":
|
|
2560
|
-
await
|
|
3601
|
+
await startConflictPrompt(effect.conflicts, ctx);
|
|
2561
3602
|
return [];
|
|
2562
3603
|
case "REQUEST_CONFLICT_VERSIONS": {
|
|
2563
3604
|
if (!syncState.socket) {
|
|
2564
3605
|
warn("Cannot request conflict versions without active socket");
|
|
2565
3606
|
return [];
|
|
2566
3607
|
}
|
|
2567
|
-
const persistedState =
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
});
|
|
2575
|
-
debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
|
|
2576
|
-
await sendMessage(syncState.socket, {
|
|
3608
|
+
const persistedState = runtime.metadata.getPersistedState();
|
|
3609
|
+
const conflicts = effect.conflicts.map((conflict) => ({
|
|
3610
|
+
fileName: conflict.fileName,
|
|
3611
|
+
lastSyncedAt: conflict.lastSyncedAt ?? persistedState.get(conflict.fileName)?.timestamp
|
|
3612
|
+
}));
|
|
3613
|
+
debug(`Requesting remote version data for ${pluralize(conflicts.length, "file")}`);
|
|
3614
|
+
await sendToPlugin(syncState.socket, {
|
|
2577
3615
|
type: "conflict-version-request",
|
|
2578
|
-
conflicts
|
|
3616
|
+
conflicts
|
|
2579
3617
|
});
|
|
2580
3618
|
return [];
|
|
2581
3619
|
}
|
|
2582
3620
|
case "UPDATE_FILE_METADATA": {
|
|
2583
|
-
if (!
|
|
2584
|
-
const currentContent = await readFileSafe(effect.fileName,
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
3621
|
+
if (!runtime.workspace.filesDir || !runtime.workspace.projectDir) return [];
|
|
3622
|
+
const currentContent = await readFileSafe(effect.fileName, runtime.workspace.filesDir);
|
|
3623
|
+
const pendingRename = runtime.getPendingRename(normalizeCodeFilePathWithExtension(effect.fileName));
|
|
3624
|
+
const syncedContent = currentContent ?? pendingRename?.content ?? null;
|
|
3625
|
+
if (syncedContent !== null) runtime.memory.recordSyncedContent(effect.fileName, syncedContent, effect.remoteModifiedAt);
|
|
3626
|
+
if (pendingRename) {
|
|
3627
|
+
runtime.memory.recordSyncedDelete(pendingRename.oldFileName);
|
|
3628
|
+
if (currentContent !== null) runtime.memory.armContentEcho(effect.fileName, currentContent);
|
|
3629
|
+
runtime.completePendingRename(effect.fileName);
|
|
2588
3630
|
}
|
|
2589
3631
|
return [];
|
|
2590
3632
|
}
|
|
2591
|
-
case "SEND_LOCAL_CHANGE":
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
3633
|
+
case "SEND_LOCAL_CHANGE":
|
|
3634
|
+
await sendLocalChange(effect.fileName, effect.content, ctx);
|
|
3635
|
+
return [];
|
|
3636
|
+
case "SEND_FILE_RENAME":
|
|
3637
|
+
await sendFileRename(effect, ctx);
|
|
3638
|
+
return [];
|
|
3639
|
+
case "LOCAL_INITIATED_FILE_DELETE": {
|
|
3640
|
+
const filesToDelete = [];
|
|
3641
|
+
for (const fileName of effect.fileNames) if (runtime.memory.matchesExpectedDeleteEcho(fileName)) runtime.memory.clearExpectedDeleteEcho(fileName);
|
|
3642
|
+
else filesToDelete.push(fileName);
|
|
3643
|
+
if (filesToDelete.length === 0) return [];
|
|
3644
|
+
if (config.dangerouslyAutoDelete) await sendFileDelete(filesToDelete, ctx);
|
|
3645
|
+
else await startDeletePrompt(filesToDelete, ctx);
|
|
3646
|
+
return [];
|
|
3647
|
+
}
|
|
3648
|
+
case "RESOLVE_DELETE_PROMPT": {
|
|
3649
|
+
const activeFileNames = runtime.getDeletePromptFileNames(effect.session, [...effect.confirmedFileNames, ...effect.cancelledFiles.map((file) => file.fileName)]);
|
|
3650
|
+
if (!activeFileNames) {
|
|
3651
|
+
warn("Ignoring stale delete prompt response (session or paths mismatch)");
|
|
2595
3652
|
return [];
|
|
2596
3653
|
}
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
3654
|
+
const active = new Set(activeFileNames);
|
|
3655
|
+
const confirmed = effect.confirmedFileNames.filter((fileName) => active.has(runtime.memory.normalizePath(fileName)));
|
|
3656
|
+
const cancelled = effect.cancelledFiles.filter((file) => active.has(runtime.memory.normalizePath(file.fileName)));
|
|
3657
|
+
if (cancelled.length > 0) await writeFiles(cancelled.map((file) => ({
|
|
3658
|
+
name: file.fileName,
|
|
3659
|
+
content: file.content,
|
|
3660
|
+
modifiedAt: Date.now()
|
|
3661
|
+
})), ctx, { echoPolicy: "authoritative" });
|
|
3662
|
+
await sendFileDelete(confirmed, ctx);
|
|
3663
|
+
runtime.clearDeletePromptFiles(effect.session, activeFileNames);
|
|
3664
|
+
await runtime.metadata.flush();
|
|
3665
|
+
await flushPendingSyncComplete(ctx);
|
|
3666
|
+
return [];
|
|
3667
|
+
}
|
|
3668
|
+
case "RESOLVE_CONFLICT_PROMPT": {
|
|
3669
|
+
const conflicts = runtime.getConflictPromptConflicts(effect.session, effect.fileNames);
|
|
3670
|
+
if (!conflicts) {
|
|
3671
|
+
warn("Ignoring stale conflicts-resolved (session mismatch)");
|
|
3672
|
+
return [];
|
|
3673
|
+
}
|
|
3674
|
+
if (effect.resolution === "remote") {
|
|
3675
|
+
const filesToWrite = [];
|
|
3676
|
+
const filesToDelete = [];
|
|
3677
|
+
for (const conflict of conflicts) {
|
|
3678
|
+
if (conflict.remoteContent === null) {
|
|
3679
|
+
filesToDelete.push(conflict.fileName);
|
|
3680
|
+
continue;
|
|
3681
|
+
}
|
|
3682
|
+
filesToWrite.push({
|
|
3683
|
+
name: conflict.fileName,
|
|
3684
|
+
content: conflict.remoteContent,
|
|
3685
|
+
modifiedAt: conflict.remoteModifiedAt
|
|
2605
3686
|
});
|
|
2606
|
-
fileUp(effect.fileName);
|
|
2607
3687
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
}
|
|
3688
|
+
await Promise.all([writeFiles(filesToWrite, ctx, {
|
|
3689
|
+
silent: true,
|
|
3690
|
+
echoPolicy: "authoritative"
|
|
3691
|
+
}), deleteFiles(filesToDelete, ctx)]);
|
|
3692
|
+
} else for (const conflict of conflicts) if (conflict.localContent === null) await sendFileDelete([conflict.fileName], ctx);
|
|
3693
|
+
else await sendLocalChange(conflict.fileName, conflict.localContent, ctx);
|
|
3694
|
+
success(effect.resolution === "remote" ? "Keeping Framer changes" : "Keeping local changes");
|
|
3695
|
+
runtime.clearConflictPromptFiles(effect.session, effect.fileNames);
|
|
3696
|
+
await runtime.metadata.flush();
|
|
3697
|
+
await applySyncComplete({
|
|
3698
|
+
type: "SYNC_COMPLETE",
|
|
3699
|
+
totalCount: conflicts.length,
|
|
3700
|
+
updatedCount: conflicts.length,
|
|
3701
|
+
unchangedCount: 0
|
|
3702
|
+
}, ctx);
|
|
2613
3703
|
return [];
|
|
2614
3704
|
}
|
|
2615
|
-
case "
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
hashTracker.forget(fileName);
|
|
2629
|
-
fileMetadataCache.recordDelete(fileName);
|
|
2630
|
-
fileDelete(fileName);
|
|
2631
|
-
}
|
|
2632
|
-
if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
|
|
2633
|
-
type: "file-delete",
|
|
2634
|
-
fileNames: confirmedFiles
|
|
3705
|
+
case "UPDATE_ACTIVE_CONFLICT_LOCAL":
|
|
3706
|
+
await applyConflictChange(runtime.updateActiveConflictLocal(effect.fileName, effect.content, effect.modifiedAt), ctx);
|
|
3707
|
+
return [];
|
|
3708
|
+
case "UPDATE_ACTIVE_CONFLICT_REMOTE":
|
|
3709
|
+
await applyConflictChange(runtime.updateActiveConflictRemote(effect.fileName, effect.content, effect.modifiedAt), ctx);
|
|
3710
|
+
return [];
|
|
3711
|
+
case "INVALIDATE_DELETE_PROMPT_PATH": {
|
|
3712
|
+
const change = runtime.invalidateDeletePromptPath(effect.fileName);
|
|
3713
|
+
if (change.changed) {
|
|
3714
|
+
await sendToPlugin(syncState.socket, {
|
|
3715
|
+
type: "delete-prompt-cleared",
|
|
3716
|
+
session: change.session,
|
|
3717
|
+
fileNames: change.fileNames
|
|
2635
3718
|
});
|
|
2636
|
-
|
|
2637
|
-
console.warn(`Failed to handle deletion for ${filesToDelete.join(", ")}:`, err);
|
|
3719
|
+
await flushPendingSyncComplete(ctx);
|
|
2638
3720
|
}
|
|
2639
3721
|
return [];
|
|
2640
3722
|
}
|
|
2641
3723
|
case "PERSIST_STATE":
|
|
2642
|
-
await
|
|
3724
|
+
await runtime.metadata.flush();
|
|
2643
3725
|
return [];
|
|
2644
|
-
case "SYNC_COMPLETE":
|
|
2645
|
-
|
|
2646
|
-
if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
|
|
2647
|
-
if (wasDisconnected) {
|
|
2648
|
-
if (didShowDisconnect()) {
|
|
2649
|
-
success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
2650
|
-
status("Watching for changes...");
|
|
2651
|
-
}
|
|
2652
|
-
resetDisconnectState();
|
|
2653
|
-
return [];
|
|
2654
|
-
}
|
|
2655
|
-
const relative = config.projectDir ? path.relative(process.cwd(), config.projectDir) : null;
|
|
2656
|
-
const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
|
|
2657
|
-
if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
|
|
2658
|
-
else success(`Syncing to ${relativeDirectory} folder`);
|
|
2659
|
-
else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
|
|
2660
|
-
else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
|
|
2661
|
-
else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
2662
|
-
if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
|
|
2663
|
-
status("Watching for changes...");
|
|
3726
|
+
case "SYNC_COMPLETE":
|
|
3727
|
+
await applySyncComplete(effect, ctx);
|
|
2664
3728
|
return [];
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
success,
|
|
2671
|
-
debug
|
|
2672
|
-
}[effect.level];
|
|
2673
|
-
logFn(effect.message);
|
|
3729
|
+
case "LOG":
|
|
3730
|
+
emitLog({
|
|
3731
|
+
level: effect.level,
|
|
3732
|
+
message: effect.message
|
|
3733
|
+
});
|
|
2674
3734
|
return [];
|
|
2675
|
-
}
|
|
2676
3735
|
}
|
|
2677
3736
|
}
|
|
2678
3737
|
/**
|
|
2679
3738
|
* Starts the sync controller with the given configuration
|
|
2680
3739
|
*/
|
|
2681
3740
|
async function start(config) {
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
let installer = null;
|
|
3741
|
+
const runtime = new SyncRuntime();
|
|
3742
|
+
let isShuttingDown = false;
|
|
3743
|
+
let pendingDependencyVersions = null;
|
|
2686
3744
|
let syncState = {
|
|
2687
|
-
|
|
2688
|
-
socket: null
|
|
2689
|
-
pendingRemoteChanges: []
|
|
3745
|
+
phase: "disconnected",
|
|
3746
|
+
socket: null
|
|
2690
3747
|
};
|
|
2691
|
-
const
|
|
2692
|
-
|
|
3748
|
+
const eventQueue = createEventQueue();
|
|
3749
|
+
function nullDependencyVersions(packages) {
|
|
3750
|
+
return Object.fromEntries(packages.map((packageName) => [packageName, null]));
|
|
3751
|
+
}
|
|
3752
|
+
async function requestDependencyVersions(packages) {
|
|
3753
|
+
if (packages.length === 0) return {};
|
|
3754
|
+
const socket = syncState.socket;
|
|
3755
|
+
if (!socket) return nullDependencyVersions(packages);
|
|
3756
|
+
if (pendingDependencyVersions) {
|
|
3757
|
+
warn("Dependency version request already pending");
|
|
3758
|
+
return nullDependencyVersions(packages);
|
|
3759
|
+
}
|
|
3760
|
+
return await new Promise((resolve) => {
|
|
3761
|
+
const timeout = setTimeout(() => {
|
|
3762
|
+
if (pendingDependencyVersions?.resolve === resolve) {
|
|
3763
|
+
pendingDependencyVersions = null;
|
|
3764
|
+
warn("Timed out waiting for dependency versions from plugin");
|
|
3765
|
+
resolve(nullDependencyVersions(packages));
|
|
3766
|
+
}
|
|
3767
|
+
}, 1e4);
|
|
3768
|
+
pendingDependencyVersions = {
|
|
3769
|
+
resolve,
|
|
3770
|
+
timeout
|
|
3771
|
+
};
|
|
3772
|
+
sendMessage(socket, {
|
|
3773
|
+
type: "request-dependency-versions",
|
|
3774
|
+
packages
|
|
3775
|
+
}).then((sent) => {
|
|
3776
|
+
if (!sent && pendingDependencyVersions?.resolve === resolve) {
|
|
3777
|
+
clearTimeout(timeout);
|
|
3778
|
+
pendingDependencyVersions = null;
|
|
3779
|
+
resolve(nullDependencyVersions(packages));
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
});
|
|
3783
|
+
}
|
|
3784
|
+
function processEvent(event) {
|
|
3785
|
+
return eventQueue.enqueue(() => processEventInner(event));
|
|
3786
|
+
}
|
|
3787
|
+
async function processEventInner(event) {
|
|
2693
3788
|
const socketState = syncState.socket?.readyState;
|
|
2694
|
-
debug(`[STATE] Processing event: ${event.type} (
|
|
2695
|
-
const result = transition(syncState, event
|
|
3789
|
+
debug(`[STATE] Processing event: ${event.type} (phase: ${syncState.phase}, socket: ${socketState ?? "none"})`);
|
|
3790
|
+
const result = transition(syncState, event, {
|
|
3791
|
+
wasRecentlyDisconnected: () => runtime.disconnectUi.wasRecentlyDisconnected(),
|
|
3792
|
+
isActiveConflictPath: (fileName) => runtime.isActiveConflictPath(fileName),
|
|
3793
|
+
isActiveDeletePromptPath: (fileName) => runtime.isActiveDeletePromptPath(fileName)
|
|
3794
|
+
});
|
|
2696
3795
|
syncState = result.state;
|
|
2697
3796
|
if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
|
|
2698
3797
|
for (const effect of result.effects) {
|
|
2699
3798
|
const currentSocketState = syncState.socket?.readyState;
|
|
2700
3799
|
if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
|
|
2701
|
-
const followUpEvents = await
|
|
3800
|
+
const followUpEvents = await applyEffect(effect, {
|
|
2702
3801
|
config,
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
fileMetadataCache,
|
|
2706
|
-
userActions,
|
|
3802
|
+
runtime,
|
|
3803
|
+
shutdown,
|
|
2707
3804
|
syncState
|
|
2708
3805
|
});
|
|
2709
|
-
for (const followUpEvent of followUpEvents) await
|
|
3806
|
+
for (const followUpEvent of followUpEvents) await processEventInner(followUpEvent);
|
|
2710
3807
|
}
|
|
2711
3808
|
}
|
|
2712
|
-
const
|
|
3809
|
+
const certs = await getOrCreateCerts();
|
|
3810
|
+
if (!certs) {
|
|
3811
|
+
error("Failed to generate TLS certificates. The Framer plugin requires a secure (wss://) connection.");
|
|
3812
|
+
info("");
|
|
3813
|
+
info("To fix this:");
|
|
3814
|
+
info(" 1. Re-run this command — certificate generation is often a one-time issue");
|
|
3815
|
+
info(` 2. Manually delete "${CERT_DIR}" and try again`);
|
|
3816
|
+
info("");
|
|
3817
|
+
throw new Error("TLS certificate generation failed");
|
|
3818
|
+
}
|
|
3819
|
+
status("Waiting for Plugin connection...");
|
|
3820
|
+
const connection = await initConnection(config.port, certs);
|
|
2713
3821
|
connection.on("handshake", (client, message) => {
|
|
2714
3822
|
debug(`Received handshake: ${message.projectName} (${message.projectId})`);
|
|
2715
3823
|
const expectedShort = shortProjectHash(config.projectHash);
|
|
@@ -2720,16 +3828,23 @@ async function start(config) {
|
|
|
2720
3828
|
return;
|
|
2721
3829
|
}
|
|
2722
3830
|
(async () => {
|
|
2723
|
-
|
|
2724
|
-
if (syncState.
|
|
3831
|
+
runtime.disconnectUi.cancelNotice();
|
|
3832
|
+
if (syncState.phase !== "disconnected") {
|
|
2725
3833
|
if (syncState.socket === client) {
|
|
2726
|
-
|
|
3834
|
+
await processEvent({
|
|
3835
|
+
type: "RESEND_SYNC_STATUS",
|
|
3836
|
+
status: runtime.lastEmittedSyncStatus ?? "initial_sync"
|
|
3837
|
+
});
|
|
2727
3838
|
return;
|
|
2728
3839
|
}
|
|
2729
|
-
debug(`New handshake received
|
|
3840
|
+
debug(`New handshake received (phase=${syncState.phase}), resetting sync state`);
|
|
3841
|
+
runtime.clearPendingRenames();
|
|
3842
|
+
runtime.clearEmittedSyncStatus();
|
|
3843
|
+
runtime.cleanupUserActions();
|
|
2730
3844
|
await processEvent({ type: "DISCONNECT" });
|
|
2731
3845
|
}
|
|
2732
|
-
|
|
3846
|
+
runtime.mintConnectionId();
|
|
3847
|
+
if (!runtime.disconnectUi.wasRecentlyDisconnected() && !runtime.disconnectUi.didShowNotice()) success(`Connected to ${message.projectName}`);
|
|
2733
3848
|
await processEvent({
|
|
2734
3849
|
type: "HANDSHAKE",
|
|
2735
3850
|
socket: client,
|
|
@@ -2738,18 +3853,20 @@ async function start(config) {
|
|
|
2738
3853
|
projectName: message.projectName
|
|
2739
3854
|
}
|
|
2740
3855
|
});
|
|
2741
|
-
if (
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
3856
|
+
if (runtime.workspace.projectDir && !runtime.installer) {
|
|
3857
|
+
const npmStrategy = await resolveNpmStrategy(config, runtime.workspace.projectDir);
|
|
3858
|
+
runtime.installer = new Installer({
|
|
3859
|
+
projectDir: runtime.workspace.projectDir,
|
|
3860
|
+
npmStrategy,
|
|
3861
|
+
requestDependencyVersions
|
|
2745
3862
|
});
|
|
2746
|
-
await installer.initialize();
|
|
3863
|
+
await runtime.installer.initialize();
|
|
2747
3864
|
startWatcher();
|
|
2748
3865
|
}
|
|
2749
3866
|
})();
|
|
2750
3867
|
});
|
|
2751
3868
|
async function handleMessage(message) {
|
|
2752
|
-
if (!
|
|
3869
|
+
if (!runtime.workspace.projectDir || !runtime.installer) {
|
|
2753
3870
|
warn("Received message before handshake completed - ignoring");
|
|
2754
3871
|
return;
|
|
2755
3872
|
}
|
|
@@ -2772,8 +3889,7 @@ async function start(config) {
|
|
|
2772
3889
|
name: message.fileName,
|
|
2773
3890
|
content: message.content,
|
|
2774
3891
|
modifiedAt: Date.now()
|
|
2775
|
-
}
|
|
2776
|
-
fileMeta: fileMetadataCache.get(message.fileName)
|
|
3892
|
+
}
|
|
2777
3893
|
};
|
|
2778
3894
|
break;
|
|
2779
3895
|
case "file-delete":
|
|
@@ -2782,25 +3898,20 @@ async function start(config) {
|
|
|
2782
3898
|
fileName
|
|
2783
3899
|
});
|
|
2784
3900
|
return;
|
|
2785
|
-
case "delete-confirmed":
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
return;
|
|
2793
|
-
}
|
|
3901
|
+
case "delete-confirmed":
|
|
3902
|
+
event = {
|
|
3903
|
+
type: "DELETE_CONFIRMED",
|
|
3904
|
+
session: message.session,
|
|
3905
|
+
fileNames: message.fileNames
|
|
3906
|
+
};
|
|
3907
|
+
break;
|
|
2794
3908
|
case "delete-cancelled":
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
});
|
|
2802
|
-
}
|
|
2803
|
-
return;
|
|
3909
|
+
event = {
|
|
3910
|
+
type: "DELETE_CANCELLED",
|
|
3911
|
+
session: message.session,
|
|
3912
|
+
files: message.files
|
|
3913
|
+
};
|
|
3914
|
+
break;
|
|
2804
3915
|
case "file-synced":
|
|
2805
3916
|
event = {
|
|
2806
3917
|
type: "FILE_SYNCED_CONFIRMATION",
|
|
@@ -2808,18 +3919,35 @@ async function start(config) {
|
|
|
2808
3919
|
remoteModifiedAt: message.remoteModifiedAt
|
|
2809
3920
|
};
|
|
2810
3921
|
break;
|
|
3922
|
+
case "error":
|
|
3923
|
+
if (message.fileName) runtime.completePendingRename(normalizeCodeFilePathWithExtension(message.fileName));
|
|
3924
|
+
warn(message.message);
|
|
3925
|
+
return;
|
|
2811
3926
|
case "conflicts-resolved":
|
|
2812
3927
|
event = {
|
|
2813
3928
|
type: "CONFLICTS_RESOLVED",
|
|
2814
|
-
|
|
3929
|
+
session: message.session,
|
|
3930
|
+
resolution: message.resolution,
|
|
3931
|
+
fileNames: message.fileNames
|
|
2815
3932
|
};
|
|
2816
3933
|
break;
|
|
2817
3934
|
case "conflict-version-response":
|
|
2818
3935
|
event = {
|
|
2819
|
-
type: "
|
|
3936
|
+
type: "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS",
|
|
2820
3937
|
versions: message.versions
|
|
2821
3938
|
};
|
|
2822
3939
|
break;
|
|
3940
|
+
case "dependency-versions": {
|
|
3941
|
+
if (!pendingDependencyVersions) {
|
|
3942
|
+
warn("Received dependency versions with no pending request");
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3945
|
+
clearTimeout(pendingDependencyVersions.timeout);
|
|
3946
|
+
const pending = pendingDependencyVersions;
|
|
3947
|
+
pendingDependencyVersions = null;
|
|
3948
|
+
pending.resolve(message.versions);
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
2823
3951
|
default:
|
|
2824
3952
|
warn(`Unhandled message type: ${message.type}`);
|
|
2825
3953
|
return;
|
|
@@ -2836,25 +3964,42 @@ async function start(config) {
|
|
|
2836
3964
|
})();
|
|
2837
3965
|
});
|
|
2838
3966
|
connection.on("disconnect", (client) => {
|
|
3967
|
+
if (isShuttingDown) {
|
|
3968
|
+
debug("[STATE] Ignoring disconnect during shutdown");
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
2839
3971
|
if (syncState.socket !== client) {
|
|
2840
3972
|
debug("[STATE] Ignoring disconnect from stale socket");
|
|
2841
3973
|
return;
|
|
2842
3974
|
}
|
|
2843
|
-
|
|
3975
|
+
runtime.disconnectUi.scheduleNotice(() => {
|
|
2844
3976
|
status("Disconnected, waiting to reconnect...");
|
|
2845
3977
|
});
|
|
2846
3978
|
(async () => {
|
|
3979
|
+
runtime.clearPendingRenames();
|
|
2847
3980
|
await processEvent({ type: "DISCONNECT" });
|
|
2848
|
-
|
|
3981
|
+
runtime.clearEmittedSyncStatus();
|
|
3982
|
+
runtime.cleanupUserActions();
|
|
2849
3983
|
})();
|
|
2850
3984
|
});
|
|
2851
3985
|
connection.on("error", (err) => {
|
|
2852
3986
|
error("Error on WebSocket connection:", err);
|
|
2853
3987
|
});
|
|
2854
3988
|
let watcher = null;
|
|
3989
|
+
const shutdown = async () => {
|
|
3990
|
+
if (isShuttingDown) return;
|
|
3991
|
+
debug("[STATE] Shutting down...");
|
|
3992
|
+
isShuttingDown = true;
|
|
3993
|
+
runtime.cleanupUserActions();
|
|
3994
|
+
if (watcher) {
|
|
3995
|
+
await watcher.close();
|
|
3996
|
+
watcher = null;
|
|
3997
|
+
}
|
|
3998
|
+
connection.close();
|
|
3999
|
+
};
|
|
2855
4000
|
const startWatcher = () => {
|
|
2856
|
-
if (!
|
|
2857
|
-
watcher = initWatcher(
|
|
4001
|
+
if (!runtime.workspace.filesDir || watcher) return;
|
|
4002
|
+
watcher = initWatcher(runtime.workspace.filesDir);
|
|
2858
4003
|
watcher.on("change", (event) => {
|
|
2859
4004
|
processEvent({
|
|
2860
4005
|
type: "WATCHER_EVENT",
|
|
@@ -2866,8 +4011,7 @@ async function start(config) {
|
|
|
2866
4011
|
console.log();
|
|
2867
4012
|
status("Shutting down...");
|
|
2868
4013
|
(async () => {
|
|
2869
|
-
|
|
2870
|
-
connection.close();
|
|
4014
|
+
await shutdown();
|
|
2871
4015
|
process.exit(0);
|
|
2872
4016
|
})();
|
|
2873
4017
|
});
|
|
@@ -2883,6 +4027,11 @@ async function start(config) {
|
|
|
2883
4027
|
*/
|
|
2884
4028
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
2885
4029
|
const program = new Command();
|
|
4030
|
+
function parseUnsupportedNpmMode(mode) {
|
|
4031
|
+
if (mode === void 0) return "acquire-types";
|
|
4032
|
+
if (mode === "acquire-types" || mode === "package-manager") return mode;
|
|
4033
|
+
throw new InvalidArgumentError("unsupported npm mode must be 'acquire-types' or 'package-manager'");
|
|
4034
|
+
}
|
|
2886
4035
|
program.exitOverride((err) => {
|
|
2887
4036
|
if (err.code === "commander.missingArgument") {
|
|
2888
4037
|
console.error("Missing Project ID. Copy command via Code Link Plugin.");
|
|
@@ -2890,7 +4039,7 @@ program.exitOverride((err) => {
|
|
|
2890
4039
|
}
|
|
2891
4040
|
throw err;
|
|
2892
4041
|
});
|
|
2893
|
-
program.name("framer-code-link").description("Sync Framer code components to your local filesystem").version(version).argument("[projectHash]", "Framer Project ID Hash (auto-detected from package.json if omitted)").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").option("--unsupported-npm", "
|
|
4042
|
+
program.name("framer-code-link").description("Sync Framer code components to your local filesystem").version(version).argument("[projectHash]", "Framer Project ID Hash (auto-detected from package.json if omitted)").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("--once", "Exit after the initial sync completes").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").option("--unsupported-npm [mode]", "Handle unsupported npm packages (acquire-types or package-manager)", parseUnsupportedNpmMode).action(async (projectHash, options) => {
|
|
2894
4043
|
if (!projectHash) {
|
|
2895
4044
|
const detected = await getProjectHashFromCwd();
|
|
2896
4045
|
if (detected) projectHash = detected;
|
|
@@ -2917,7 +4066,8 @@ program.name("framer-code-link").description("Sync Framer code components to you
|
|
|
2917
4066
|
projectDir: null,
|
|
2918
4067
|
filesDir: null,
|
|
2919
4068
|
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
2920
|
-
|
|
4069
|
+
npmStrategy: options.unsupportedNpm,
|
|
4070
|
+
once: options.once ?? false,
|
|
2921
4071
|
explicitDirectory: options.dir,
|
|
2922
4072
|
explicitName: options.name
|
|
2923
4073
|
};
|