framer-code-link 0.18.0 → 0.21.0-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 +1565 -858
- package/package.json +4 -3
package/dist/index.mjs
CHANGED
|
@@ -1,15 +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 {
|
|
7
|
-
import {
|
|
8
|
-
import nodeFs from "fs";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
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";
|
|
11
13
|
import https from "node:https";
|
|
12
14
|
import { WebSocketServer } from "ws";
|
|
15
|
+
import { createHash as createHash$1 } from "crypto";
|
|
16
|
+
import { execSync } from "child_process";
|
|
17
|
+
import fs$2 from "fs";
|
|
13
18
|
import { setupTypeAcquisition } from "@typescript/ata";
|
|
14
19
|
import ts from "typescript";
|
|
15
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -312,14 +317,22 @@ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
312
317
|
//#endregion
|
|
313
318
|
//#region src/utils/logging.ts
|
|
314
319
|
var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
};
|
|
322
332
|
let currentLevel = LogLevel.INFO;
|
|
333
|
+
function allows(level) {
|
|
334
|
+
return LOG_PRIORITY[currentLevel] <= LOG_PRIORITY[level];
|
|
335
|
+
}
|
|
323
336
|
let lastMessage = "";
|
|
324
337
|
let lastMessageCount = 0;
|
|
325
338
|
const CLEAR_LINE = "\x1B[2K";
|
|
@@ -328,10 +341,6 @@ function rewriteLastLine(text) {
|
|
|
328
341
|
if (process.stdout.isTTY) process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`);
|
|
329
342
|
else process.stdout.write(`${text}\n`);
|
|
330
343
|
}
|
|
331
|
-
let disconnectTimer = null;
|
|
332
|
-
let isShowingDisconnect = false;
|
|
333
|
-
let hadRecentDisconnect = false;
|
|
334
|
-
const DISCONNECT_DELAY_MS = 4e3;
|
|
335
344
|
function setLogLevel(level) {
|
|
336
345
|
currentLevel = level;
|
|
337
346
|
}
|
|
@@ -365,7 +374,7 @@ function logWithDedupe(message, writer) {
|
|
|
365
374
|
function banner(version, port) {
|
|
366
375
|
console.log();
|
|
367
376
|
let message = ` ${import_picocolors.default.cyan(import_picocolors.default.bold("⚡ Code Link"))} ${import_picocolors.default.dim(`v${version}`)}`;
|
|
368
|
-
if (
|
|
377
|
+
if (allows(LogLevel.DEBUG)) message += ` ${import_picocolors.default.dim("Port")} ${import_picocolors.default.yellow(port)}`;
|
|
369
378
|
console.log(message);
|
|
370
379
|
console.log();
|
|
371
380
|
}
|
|
@@ -373,13 +382,13 @@ function banner(version, port) {
|
|
|
373
382
|
* Debug-level logging - only shown with --verbose flag
|
|
374
383
|
*/
|
|
375
384
|
function debug(message, ...args) {
|
|
376
|
-
if (
|
|
385
|
+
if (allows(LogLevel.DEBUG)) console.debug(import_picocolors.default.dim(`[DEBUG] ${message}`), ...args);
|
|
377
386
|
}
|
|
378
387
|
/**
|
|
379
388
|
* Info-level logging - shown by default, no prefix
|
|
380
389
|
*/
|
|
381
390
|
function info(message, ...args) {
|
|
382
|
-
if (
|
|
391
|
+
if (allows(LogLevel.INFO)) {
|
|
383
392
|
const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message;
|
|
384
393
|
logWithDedupe(formatted, () => {
|
|
385
394
|
console.log(formatted);
|
|
@@ -390,7 +399,7 @@ function info(message, ...args) {
|
|
|
390
399
|
* Warning-level logging
|
|
391
400
|
*/
|
|
392
401
|
function warn(message, ...args) {
|
|
393
|
-
if (
|
|
402
|
+
if (allows(LogLevel.WARN)) {
|
|
394
403
|
if (message === lastMessage) return;
|
|
395
404
|
flushDedupe();
|
|
396
405
|
lastMessage = message;
|
|
@@ -402,7 +411,7 @@ function warn(message, ...args) {
|
|
|
402
411
|
* Error-level logging
|
|
403
412
|
*/
|
|
404
413
|
function error(message, ...args) {
|
|
405
|
-
if (
|
|
414
|
+
if (allows(LogLevel.ERROR)) {
|
|
406
415
|
flushDedupe();
|
|
407
416
|
console.error(import_picocolors.default.red(`✗ ${message}`), ...args);
|
|
408
417
|
}
|
|
@@ -411,7 +420,7 @@ function error(message, ...args) {
|
|
|
411
420
|
* Success message with checkmark
|
|
412
421
|
*/
|
|
413
422
|
function success(message, ...args) {
|
|
414
|
-
if (
|
|
423
|
+
if (allows(LogLevel.INFO)) {
|
|
415
424
|
flushDedupe();
|
|
416
425
|
console.log(import_picocolors.default.green(`✓ ${message}`), ...args);
|
|
417
426
|
}
|
|
@@ -420,7 +429,7 @@ function success(message, ...args) {
|
|
|
420
429
|
* File sync indicators
|
|
421
430
|
*/
|
|
422
431
|
function fileDown(fileName) {
|
|
423
|
-
if (
|
|
432
|
+
if (allows(LogLevel.INFO)) {
|
|
424
433
|
const msg = ` ${import_picocolors.default.blue("↓")} ${fileName}`;
|
|
425
434
|
logWithDedupe(msg, () => {
|
|
426
435
|
console.log(msg);
|
|
@@ -428,7 +437,7 @@ function fileDown(fileName) {
|
|
|
428
437
|
}
|
|
429
438
|
}
|
|
430
439
|
function fileUp(fileName) {
|
|
431
|
-
if (
|
|
440
|
+
if (allows(LogLevel.INFO)) {
|
|
432
441
|
const msg = ` ${import_picocolors.default.green("↑")} ${fileName}`;
|
|
433
442
|
logWithDedupe(msg, () => {
|
|
434
443
|
console.log(msg);
|
|
@@ -436,7 +445,7 @@ function fileUp(fileName) {
|
|
|
436
445
|
}
|
|
437
446
|
}
|
|
438
447
|
function fileDelete(fileName) {
|
|
439
|
-
if (
|
|
448
|
+
if (allows(LogLevel.INFO)) {
|
|
440
449
|
const msg = ` ${import_picocolors.default.red("×")} ${fileName}`;
|
|
441
450
|
logWithDedupe(msg, () => {
|
|
442
451
|
console.log(msg);
|
|
@@ -447,53 +456,11 @@ function fileDelete(fileName) {
|
|
|
447
456
|
* Status message (dimmed, for "watching for changes..." etc)
|
|
448
457
|
*/
|
|
449
458
|
function status(message) {
|
|
450
|
-
if (
|
|
459
|
+
if (allows(LogLevel.INFO)) {
|
|
451
460
|
flushDedupe();
|
|
452
461
|
console.log(import_picocolors.default.dim(` ${message}`));
|
|
453
462
|
}
|
|
454
463
|
}
|
|
455
|
-
/**
|
|
456
|
-
* Schedule a delayed disconnect message.
|
|
457
|
-
* If reconnection happens before the delay, the message is cancelled.
|
|
458
|
-
*/
|
|
459
|
-
function scheduleDisconnectMessage(callback) {
|
|
460
|
-
if (disconnectTimer) clearTimeout(disconnectTimer);
|
|
461
|
-
hadRecentDisconnect = true;
|
|
462
|
-
isShowingDisconnect = false;
|
|
463
|
-
disconnectTimer = setTimeout(() => {
|
|
464
|
-
isShowingDisconnect = true;
|
|
465
|
-
callback();
|
|
466
|
-
disconnectTimer = null;
|
|
467
|
-
}, DISCONNECT_DELAY_MS);
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Cancel pending disconnect message (called on reconnect)
|
|
471
|
-
*/
|
|
472
|
-
function cancelDisconnectMessage() {
|
|
473
|
-
if (disconnectTimer) {
|
|
474
|
-
clearTimeout(disconnectTimer);
|
|
475
|
-
disconnectTimer = null;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Check if we showed a disconnect message (need to show reconnect)
|
|
480
|
-
*/
|
|
481
|
-
function didShowDisconnect() {
|
|
482
|
-
return isShowingDisconnect;
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Check if we recently saw a disconnect (even if the message was suppressed)
|
|
486
|
-
*/
|
|
487
|
-
function wasRecentlyDisconnected() {
|
|
488
|
-
return hadRecentDisconnect;
|
|
489
|
-
}
|
|
490
|
-
/**
|
|
491
|
-
* Reset disconnect state after successful reconnect
|
|
492
|
-
*/
|
|
493
|
-
function resetDisconnectState() {
|
|
494
|
-
isShowingDisconnect = false;
|
|
495
|
-
hadRecentDisconnect = false;
|
|
496
|
-
}
|
|
497
464
|
|
|
498
465
|
//#endregion
|
|
499
466
|
//#region src/helpers/certs.ts
|
|
@@ -512,13 +479,13 @@ function resetDisconnectState() {
|
|
|
512
479
|
const execFileAsync = promisify(execFile);
|
|
513
480
|
/** Keep in sync with MKCERT_CHECKSUMS below. */
|
|
514
481
|
const MKCERT_VERSION = "v1.4.4";
|
|
515
|
-
const CERT_DIR = process.env.FRAMER_CODE_LINK_CERT_DIR ?? path.join(os.homedir(), ".framer", "code-link");
|
|
482
|
+
const CERT_DIR = process.env.FRAMER_CODE_LINK_CERT_DIR ?? path$1.join(os.homedir(), ".framer", "code-link");
|
|
516
483
|
const MKCERT_BIN_NAME = process.platform === "win32" ? "mkcert.exe" : "mkcert";
|
|
517
|
-
const MKCERT_BIN_PATH = path.join(CERT_DIR, MKCERT_BIN_NAME);
|
|
518
|
-
const ROOT_CA_CERT_PATH = path.join(CERT_DIR, "rootCA.pem");
|
|
519
|
-
const ROOT_CA_KEY_PATH = path.join(CERT_DIR, "rootCA-key.pem");
|
|
520
|
-
const SERVER_KEY_PATH = path.join(CERT_DIR, "localhost-key.pem");
|
|
521
|
-
const SERVER_CERT_PATH = path.join(CERT_DIR, "localhost.pem");
|
|
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");
|
|
522
489
|
/**
|
|
523
490
|
* SHA-256 checksums for mkcert v1.4.4 release binaries, keyed by "platform-arch".
|
|
524
491
|
* These must be updated whenever MKCERT_VERSION changes.
|
|
@@ -546,7 +513,7 @@ const MKCERT_ENV = {
|
|
|
546
513
|
*/
|
|
547
514
|
async function getOrCreateCerts() {
|
|
548
515
|
try {
|
|
549
|
-
await fs.mkdir(CERT_DIR, { recursive: true });
|
|
516
|
+
await fs$1.mkdir(CERT_DIR, { recursive: true });
|
|
550
517
|
const mkcertPath = await ensureMkcertBinary();
|
|
551
518
|
const rootCAState = await syncRootCA(mkcertPath);
|
|
552
519
|
if (rootCAState !== "unchanged") await invalidateServerCerts(rootCAState);
|
|
@@ -562,9 +529,10 @@ async function getOrCreateCerts() {
|
|
|
562
529
|
if (existingKey || existingCert) await invalidateIncompleteServerBundle();
|
|
563
530
|
status("Generating local certificates to connect securely. You may be asked for your password.");
|
|
564
531
|
await generateCerts(mkcertPath);
|
|
532
|
+
status("Successfully generated certificates.");
|
|
565
533
|
return {
|
|
566
|
-
key: await fs.readFile(SERVER_KEY_PATH, "utf-8"),
|
|
567
|
-
cert: await fs.readFile(SERVER_CERT_PATH, "utf-8")
|
|
534
|
+
key: await fs$1.readFile(SERVER_KEY_PATH, "utf-8"),
|
|
535
|
+
cert: await fs$1.readFile(SERVER_CERT_PATH, "utf-8")
|
|
568
536
|
};
|
|
569
537
|
} catch (err) {
|
|
570
538
|
error(`Failed to set up TLS certificates: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -595,7 +563,7 @@ function getDownloadInfo() {
|
|
|
595
563
|
async function ensureMkcertBinary() {
|
|
596
564
|
const { url, expectedChecksum } = getDownloadInfo();
|
|
597
565
|
try {
|
|
598
|
-
await fs.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
566
|
+
await fs$1.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
599
567
|
if (await verifyFileChecksum(MKCERT_BIN_PATH, expectedChecksum)) {
|
|
600
568
|
debug("mkcert binary already available and verified");
|
|
601
569
|
return MKCERT_BIN_PATH;
|
|
@@ -610,11 +578,11 @@ async function ensureMkcertBinary() {
|
|
|
610
578
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
611
579
|
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
612
580
|
if (actualChecksum !== expectedChecksum) throw new Error(`mkcert binary checksum mismatch — the download may have been tampered with.\n Expected: ${expectedChecksum}\n Actual: ${actualChecksum}`);
|
|
613
|
-
await fs.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
581
|
+
await fs$1.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
614
582
|
debug(`mkcert binary saved to ${MKCERT_BIN_PATH}`);
|
|
615
583
|
return MKCERT_BIN_PATH;
|
|
616
584
|
} catch (err) {
|
|
617
|
-
await fs.rm(MKCERT_BIN_PATH, { force: true });
|
|
585
|
+
await fs$1.rm(MKCERT_BIN_PATH, { force: true });
|
|
618
586
|
const message = err instanceof Error ? err.message : String(err);
|
|
619
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`);
|
|
620
588
|
}
|
|
@@ -660,13 +628,13 @@ async function syncRootCA(mkcertPath) {
|
|
|
660
628
|
} });
|
|
661
629
|
const defaultCAROOT = stdout.trim();
|
|
662
630
|
if (!defaultCAROOT || defaultCAROOT === CERT_DIR) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
663
|
-
const defaultRootCert = await loadFile(path.join(defaultCAROOT, "rootCA.pem"));
|
|
664
|
-
const defaultRootKey = await loadFile(path.join(defaultCAROOT, "rootCA-key.pem"));
|
|
631
|
+
const defaultRootCert = await loadFile(path$1.join(defaultCAROOT, "rootCA.pem"));
|
|
632
|
+
const defaultRootKey = await loadFile(path$1.join(defaultCAROOT, "rootCA-key.pem"));
|
|
665
633
|
if (!defaultRootCert || !defaultRootKey) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
666
634
|
if (existingRootCert === defaultRootCert && existingRootKey === defaultRootKey) return "unchanged";
|
|
667
|
-
await Promise.all([fs.rm(ROOT_CA_CERT_PATH, { force: true }), fs.rm(ROOT_CA_KEY_PATH, { force: true })]);
|
|
668
|
-
await fs.writeFile(ROOT_CA_CERT_PATH, defaultRootCert, { mode: 420 });
|
|
669
|
-
await fs.writeFile(ROOT_CA_KEY_PATH, defaultRootKey, { mode: 384 });
|
|
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 });
|
|
670
638
|
return existingRootCert && existingRootKey ? "updated" : "copied";
|
|
671
639
|
}
|
|
672
640
|
async function invalidateServerCerts(rootCAState) {
|
|
@@ -676,22 +644,22 @@ async function invalidateServerCerts(rootCAState) {
|
|
|
676
644
|
missing: "No cached mkcert root CA was available for the existing server certificate"
|
|
677
645
|
};
|
|
678
646
|
if (!(await loadFile(SERVER_KEY_PATH) !== null || await loadFile(SERVER_CERT_PATH) !== null)) return;
|
|
679
|
-
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
680
|
-
await fs.rm(SERVER_CERT_PATH, { force: true });
|
|
647
|
+
await fs$1.rm(SERVER_KEY_PATH, { force: true });
|
|
648
|
+
await fs$1.rm(SERVER_CERT_PATH, { force: true });
|
|
681
649
|
debug(`${reasons[rootCAState]}; removed stale localhost certificate`);
|
|
682
650
|
}
|
|
683
651
|
async function invalidateIncompleteServerBundle() {
|
|
684
|
-
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
685
|
-
await fs.rm(SERVER_CERT_PATH, { force: true });
|
|
652
|
+
await fs$1.rm(SERVER_KEY_PATH, { force: true });
|
|
653
|
+
await fs$1.rm(SERVER_CERT_PATH, { force: true });
|
|
686
654
|
warn("Found an incomplete localhost certificate bundle; regenerating it");
|
|
687
655
|
}
|
|
688
656
|
async function verifyFileChecksum(filePath, expectedHash) {
|
|
689
|
-
const data = await fs.readFile(filePath);
|
|
657
|
+
const data = await fs$1.readFile(filePath);
|
|
690
658
|
return createHash("sha256").update(data).digest("hex") === expectedHash;
|
|
691
659
|
}
|
|
692
660
|
async function loadFile(filePath) {
|
|
693
661
|
try {
|
|
694
|
-
return await fs.readFile(filePath, "utf-8");
|
|
662
|
+
return await fs$1.readFile(filePath, "utf-8");
|
|
695
663
|
} catch {
|
|
696
664
|
return null;
|
|
697
665
|
}
|
|
@@ -805,6 +773,7 @@ function initConnection(port, certs) {
|
|
|
805
773
|
}
|
|
806
774
|
},
|
|
807
775
|
close() {
|
|
776
|
+
for (const client of wss.clients) client.close(1001);
|
|
808
777
|
wss.close();
|
|
809
778
|
httpsServer.close();
|
|
810
779
|
}
|
|
@@ -872,7 +841,7 @@ function persistedFileKey(fileName) {
|
|
|
872
841
|
* Hash file content to detect changes
|
|
873
842
|
*/
|
|
874
843
|
function hashFileContent(content) {
|
|
875
|
-
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
844
|
+
return createHash$1("sha256").update(content, "utf-8").digest("hex");
|
|
876
845
|
}
|
|
877
846
|
/**
|
|
878
847
|
* Load persisted state from disk
|
|
@@ -1134,42 +1103,67 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
|
|
|
1134
1103
|
remainingConflicts
|
|
1135
1104
|
};
|
|
1136
1105
|
}
|
|
1137
|
-
|
|
1138
|
-
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
1139
|
-
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
1140
|
-
*/
|
|
1141
|
-
async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
|
|
1106
|
+
async function writeRemoteFiles(files, filesDir, memory) {
|
|
1142
1107
|
debug(`Writing ${pluralize(files.length, "remote file")}`);
|
|
1143
|
-
|
|
1108
|
+
const results = [];
|
|
1109
|
+
for (const file of files) {
|
|
1144
1110
|
const normalized = resolveRemoteReference(filesDir, file.name);
|
|
1145
1111
|
const fullPath = normalized.absolutePath;
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
+
}
|
|
1153
1137
|
}
|
|
1138
|
+
return results;
|
|
1154
1139
|
}
|
|
1155
|
-
|
|
1156
|
-
* Deletes a local file from disk
|
|
1157
|
-
*/
|
|
1158
|
-
async function deleteLocalFile(fileName, filesDir, hashTracker) {
|
|
1140
|
+
async function deleteLocalFile(fileName, filesDir, memory) {
|
|
1159
1141
|
const normalized = resolveRemoteReference(filesDir, fileName);
|
|
1142
|
+
const prepared = memory.armExpectedDeleteEcho(normalized.relativePath);
|
|
1160
1143
|
try {
|
|
1161
|
-
hashTracker.markDelete(normalized.relativePath);
|
|
1162
1144
|
await fs.unlink(normalized.absolutePath);
|
|
1163
|
-
hashTracker.forget(normalized.relativePath);
|
|
1164
1145
|
debug(`Deleted file: ${normalized.relativePath}`);
|
|
1146
|
+
return {
|
|
1147
|
+
fileName: normalized.relativePath,
|
|
1148
|
+
ok: true,
|
|
1149
|
+
alreadyMissing: false
|
|
1150
|
+
};
|
|
1165
1151
|
} catch (err) {
|
|
1166
1152
|
if (err.code === "ENOENT") {
|
|
1167
|
-
hashTracker.forget(normalized.relativePath);
|
|
1168
1153
|
debug(`File already deleted: ${normalized.relativePath}`);
|
|
1169
|
-
return
|
|
1154
|
+
return {
|
|
1155
|
+
fileName: normalized.relativePath,
|
|
1156
|
+
ok: true,
|
|
1157
|
+
alreadyMissing: true
|
|
1158
|
+
};
|
|
1170
1159
|
}
|
|
1171
|
-
|
|
1160
|
+
memory.rollbackExpectedDeleteEcho(prepared);
|
|
1172
1161
|
warn(`Failed to delete file ${fileName}:`, err);
|
|
1162
|
+
return {
|
|
1163
|
+
fileName: normalized.relativePath,
|
|
1164
|
+
ok: false,
|
|
1165
|
+
alreadyMissing: false
|
|
1166
|
+
};
|
|
1173
1167
|
}
|
|
1174
1168
|
}
|
|
1175
1169
|
/**
|
|
@@ -1187,9 +1181,9 @@ async function readFileSafe(fileName, filesDir) {
|
|
|
1187
1181
|
* Filter out files whose content matches the last remembered hash.
|
|
1188
1182
|
* Used to skip inbound echoes of our own local sends.
|
|
1189
1183
|
*/
|
|
1190
|
-
function filterEchoedFiles(files,
|
|
1184
|
+
function filterEchoedFiles(files, memory) {
|
|
1191
1185
|
return files.filter((file) => {
|
|
1192
|
-
return !
|
|
1186
|
+
return !memory.matchesContentEcho(file.name, file.content);
|
|
1193
1187
|
});
|
|
1194
1188
|
}
|
|
1195
1189
|
function resolveRemoteReference(filesDir, rawName) {
|
|
@@ -1284,7 +1278,7 @@ function tryGitInit(projectDir) {
|
|
|
1284
1278
|
return true;
|
|
1285
1279
|
} catch (e) {
|
|
1286
1280
|
if (didInit) try {
|
|
1287
|
-
|
|
1281
|
+
fs$2.rmSync(path.join(projectDir, ".git"), {
|
|
1288
1282
|
recursive: true,
|
|
1289
1283
|
force: true
|
|
1290
1284
|
});
|
|
@@ -1452,9 +1446,22 @@ async function findSkillsSourceDir() {
|
|
|
1452
1446
|
const FETCH_TIMEOUT_MS = 6e4;
|
|
1453
1447
|
const MAX_FETCH_RETRIES = 3;
|
|
1454
1448
|
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
1455
|
-
const
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1449
|
+
const FRAMER_PACKAGE_NAME = "framer";
|
|
1450
|
+
const CORE_LIBRARIES = [
|
|
1451
|
+
"framer-motion",
|
|
1452
|
+
"framer",
|
|
1453
|
+
"react",
|
|
1454
|
+
"react-dom"
|
|
1455
|
+
];
|
|
1456
|
+
const PACKAGE_MANAGER_DEV_DEPENDENCIES = ["@types/react", "@types/react-dom"];
|
|
1457
|
+
/** Packages with pinned type versions — used by ATA's `// types:` comment syntax */
|
|
1458
|
+
const DEFAULT_PINNED_TYPE_VERSIONS = {
|
|
1459
|
+
"framer-motion": "12.34.3",
|
|
1460
|
+
react: "18.2.0",
|
|
1461
|
+
"react-dom": "18.2.0",
|
|
1462
|
+
"@types/react": "18.2.0",
|
|
1463
|
+
"@types/react-dom": "18.2.0"
|
|
1464
|
+
};
|
|
1458
1465
|
const JSON_EXTENSION_REGEX = /\.json$/i;
|
|
1459
1466
|
/**
|
|
1460
1467
|
* Packages that are officially supported for type acquisition.
|
|
@@ -1464,6 +1471,7 @@ const SUPPORTED_PACKAGES = new Set([
|
|
|
1464
1471
|
"framer",
|
|
1465
1472
|
"framer-motion",
|
|
1466
1473
|
"react",
|
|
1474
|
+
"react-dom",
|
|
1467
1475
|
"@types/react",
|
|
1468
1476
|
"eventemitter3",
|
|
1469
1477
|
"csstype",
|
|
@@ -1475,13 +1483,19 @@ const SUPPORTED_PACKAGES = new Set([
|
|
|
1475
1483
|
*/
|
|
1476
1484
|
var Installer = class {
|
|
1477
1485
|
projectDir;
|
|
1478
|
-
|
|
1486
|
+
npmStrategy;
|
|
1487
|
+
requestDependencyVersions;
|
|
1479
1488
|
ata;
|
|
1480
1489
|
processedImports = /* @__PURE__ */ new Set();
|
|
1490
|
+
packageManagerPackages = /* @__PURE__ */ new Set();
|
|
1491
|
+
packageJsonRefreshPromise = Promise.resolve();
|
|
1481
1492
|
initializationPromise = null;
|
|
1493
|
+
pinnedTypeVersions = { ...DEFAULT_PINNED_TYPE_VERSIONS };
|
|
1494
|
+
pinnedTypeVersionsPromise = null;
|
|
1482
1495
|
constructor(config) {
|
|
1483
1496
|
this.projectDir = config.projectDir;
|
|
1484
|
-
this.
|
|
1497
|
+
this.npmStrategy = config.npmStrategy ?? "none";
|
|
1498
|
+
this.requestDependencyVersions = config.requestDependencyVersions ?? (async (packages) => Object.fromEntries(packages.map((packageName) => [packageName, null])));
|
|
1485
1499
|
const seenPackages = /* @__PURE__ */ new Set();
|
|
1486
1500
|
this.ata = setupTypeAcquisition({
|
|
1487
1501
|
projectName: "framer-code-link",
|
|
@@ -1559,10 +1573,17 @@ var Installer = class {
|
|
|
1559
1573
|
this.ensureSkills(),
|
|
1560
1574
|
this.ensureGitignore()
|
|
1561
1575
|
]);
|
|
1576
|
+
if (this.npmStrategy === "package-manager") {
|
|
1577
|
+
await this.resolvePinnedTypeVersions();
|
|
1578
|
+
await this.enqueuePackageJsonRefresh(await this.collectPackageManagerPackageNames());
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
this.pinnedTypeVersionsPromise = this.resolvePinnedTypeVersions();
|
|
1562
1582
|
Promise.resolve().then(async () => {
|
|
1563
|
-
await this.
|
|
1564
|
-
const
|
|
1565
|
-
await this.
|
|
1583
|
+
const coreImports = await this.buildPinnedImports(CORE_LIBRARIES);
|
|
1584
|
+
const packageJsonDeps = this.npmStrategy === "acquire-types" ? Object.keys(this.pinnedTypeVersions).filter((name) => !SUPPORTED_PACKAGES.has(name)) : [];
|
|
1585
|
+
const imports = [...coreImports, ...await this.buildPinnedImports(packageJsonDeps)].join("\n");
|
|
1586
|
+
await this.ata(imports);
|
|
1566
1587
|
}).catch((err) => {
|
|
1567
1588
|
debug("Type installation failed", err);
|
|
1568
1589
|
});
|
|
@@ -1570,14 +1591,20 @@ var Installer = class {
|
|
|
1570
1591
|
async processImports(fileName, content) {
|
|
1571
1592
|
const allImports = extractImports(content).filter((i) => i.type === "npm");
|
|
1572
1593
|
if (allImports.length === 0) return;
|
|
1573
|
-
|
|
1574
|
-
|
|
1594
|
+
if (this.npmStrategy === "package-manager") {
|
|
1595
|
+
await this.enqueuePackageJsonRefresh(allImports.map((imp) => getBasePackageName(imp.name)));
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const imports = this.npmStrategy === "acquire-types" ? allImports : allImports.filter((i) => this.isSupportedPackage(i.name));
|
|
1599
|
+
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)`);
|
|
1575
1600
|
if (imports.length === 0) return;
|
|
1576
|
-
|
|
1601
|
+
await this.pinnedTypeVersionsPromise;
|
|
1602
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1603
|
+
const hash = imports.map((imp) => this.pinImport(imp.name)).sort().join(",");
|
|
1577
1604
|
if (this.processedImports.has(hash)) return;
|
|
1578
1605
|
this.processedImports.add(hash);
|
|
1579
1606
|
debug(`Processing imports for ${fileName} (${imports.length} packages)`);
|
|
1580
|
-
const filteredContent = this.
|
|
1607
|
+
const filteredContent = this.npmStrategy === "acquire-types" ? content : await this.buildFilteredImports(imports);
|
|
1581
1608
|
try {
|
|
1582
1609
|
await this.ata(filteredContent);
|
|
1583
1610
|
} catch (err) {
|
|
@@ -1585,20 +1612,136 @@ var Installer = class {
|
|
|
1585
1612
|
debug(`ATA error for ${fileName}:`, err);
|
|
1586
1613
|
}
|
|
1587
1614
|
}
|
|
1615
|
+
async collectPackageManagerPackageNames() {
|
|
1616
|
+
const packageNames = new Set([...CORE_LIBRARIES, ...PACKAGE_MANAGER_DEV_DEPENDENCIES]);
|
|
1617
|
+
await this.addPackageNamesFromDirectory(path.join(this.projectDir, "files"), packageNames);
|
|
1618
|
+
return [...packageNames];
|
|
1619
|
+
}
|
|
1620
|
+
async addPackageNamesFromDirectory(directory, packageNames) {
|
|
1621
|
+
let entries;
|
|
1622
|
+
try {
|
|
1623
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
1624
|
+
} catch {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
await Promise.all(entries.map(async (entry) => {
|
|
1628
|
+
const entryPath = path.join(directory, entry.name);
|
|
1629
|
+
if (entry.isDirectory()) {
|
|
1630
|
+
await this.addPackageNamesFromDirectory(entryPath, packageNames);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (!entry.isFile() || JSON_EXTENSION_REGEX.test(entry.name)) return;
|
|
1634
|
+
try {
|
|
1635
|
+
const content = await fs.readFile(entryPath, "utf-8");
|
|
1636
|
+
for (const imported of extractImports(content).filter((i) => i.type === "npm")) packageNames.add(getBasePackageName(imported.name));
|
|
1637
|
+
} catch {}
|
|
1638
|
+
}));
|
|
1639
|
+
}
|
|
1640
|
+
async enqueuePackageJsonRefresh(packageNames) {
|
|
1641
|
+
const missingPackageNames = packageNames.map((name) => getBasePackageName(name)).filter((name) => {
|
|
1642
|
+
if (this.packageManagerPackages.has(name)) return false;
|
|
1643
|
+
this.packageManagerPackages.add(name);
|
|
1644
|
+
return true;
|
|
1645
|
+
});
|
|
1646
|
+
if (missingPackageNames.length === 0) return this.packageJsonRefreshPromise;
|
|
1647
|
+
this.packageJsonRefreshPromise = this.packageJsonRefreshPromise.then(async () => {
|
|
1648
|
+
await this.refreshPackageJsonFromPlugin(missingPackageNames);
|
|
1649
|
+
}).catch((err) => {
|
|
1650
|
+
warn("Could not refresh package.json dependency versions", err);
|
|
1651
|
+
});
|
|
1652
|
+
return this.packageJsonRefreshPromise;
|
|
1653
|
+
}
|
|
1654
|
+
async refreshPackageJsonFromPlugin(packageNames) {
|
|
1655
|
+
const uniquePackageNames = [...new Set(packageNames)].sort();
|
|
1656
|
+
const versions = await this.requestDependencyVersions(uniquePackageNames);
|
|
1657
|
+
const packagePath = path.join(this.projectDir, "package.json");
|
|
1658
|
+
const raw = await fs.readFile(packagePath, "utf-8");
|
|
1659
|
+
const pkg = JSON.parse(raw);
|
|
1660
|
+
const dependencies = typeof pkg.dependencies === "object" && pkg.dependencies !== null && !Array.isArray(pkg.dependencies) ? { ...pkg.dependencies } : {};
|
|
1661
|
+
const devDependencies = typeof pkg.devDependencies === "object" && pkg.devDependencies !== null && !Array.isArray(pkg.devDependencies) ? { ...pkg.devDependencies } : {};
|
|
1662
|
+
let changed = false;
|
|
1663
|
+
for (const packageName of uniquePackageNames) {
|
|
1664
|
+
const version = versions[packageName] ?? this.pinnedTypeVersions[packageName];
|
|
1665
|
+
if (!version) continue;
|
|
1666
|
+
const targetDependencies = PACKAGE_MANAGER_DEV_DEPENDENCIES.includes(packageName) ? devDependencies : dependencies;
|
|
1667
|
+
const oppositeDependencies = PACKAGE_MANAGER_DEV_DEPENDENCIES.includes(packageName) ? dependencies : devDependencies;
|
|
1668
|
+
if (targetDependencies[packageName] !== version) {
|
|
1669
|
+
targetDependencies[packageName] = version;
|
|
1670
|
+
changed = true;
|
|
1671
|
+
}
|
|
1672
|
+
if (oppositeDependencies[packageName] !== void 0) {
|
|
1673
|
+
delete oppositeDependencies[packageName];
|
|
1674
|
+
changed = true;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (!changed) return;
|
|
1678
|
+
pkg.dependencies = dependencies;
|
|
1679
|
+
pkg.devDependencies = devDependencies;
|
|
1680
|
+
await fs.writeFile(packagePath, JSON.stringify(pkg, null, 4));
|
|
1681
|
+
status("Updated dependencies. Run your package manager to install them.");
|
|
1682
|
+
debug(`Updated package.json dependency versions for ${uniquePackageNames.join(", ")}`);
|
|
1683
|
+
}
|
|
1588
1684
|
/**
|
|
1589
1685
|
* Check if a package is in the supported list.
|
|
1590
1686
|
* Also checks for subpath imports (e.g., "framer/build" -> "framer")
|
|
1591
1687
|
*/
|
|
1592
1688
|
isSupportedPackage(pkgName) {
|
|
1593
1689
|
if (SUPPORTED_PACKAGES.has(pkgName)) return true;
|
|
1594
|
-
const basePkg =
|
|
1690
|
+
const basePkg = getBasePackageName(pkgName);
|
|
1595
1691
|
return SUPPORTED_PACKAGES.has(basePkg);
|
|
1596
1692
|
}
|
|
1597
1693
|
/**
|
|
1598
1694
|
* Build synthetic import statements for ATA from filtered imports
|
|
1599
1695
|
*/
|
|
1600
|
-
buildFilteredImports(imports) {
|
|
1601
|
-
return imports.map((imp) =>
|
|
1696
|
+
async buildFilteredImports(imports) {
|
|
1697
|
+
return (await this.buildPinnedImports(imports.map((imp) => imp.name))).join("\n");
|
|
1698
|
+
}
|
|
1699
|
+
async buildPinnedImports(imports) {
|
|
1700
|
+
await this.pinnedTypeVersionsPromise;
|
|
1701
|
+
return imports.map((name) => this.pinImport(name));
|
|
1702
|
+
}
|
|
1703
|
+
async resolvePinnedTypeVersions() {
|
|
1704
|
+
try {
|
|
1705
|
+
const framerManifest = await fetchNpmPackageManifest(FRAMER_PACKAGE_NAME);
|
|
1706
|
+
const framerVersion = normalizePinnedVersion(framerManifest.version);
|
|
1707
|
+
if (framerVersion) this.pinnedTypeVersions.framer = framerVersion;
|
|
1708
|
+
for (const [pkg, defaultVersion] of Object.entries(DEFAULT_PINNED_TYPE_VERSIONS)) {
|
|
1709
|
+
const manifestDep = pkg.replace(/^@types\//, "");
|
|
1710
|
+
this.pinnedTypeVersions[pkg] = normalizePinnedVersion(getManifestDependencyVersion(framerManifest, manifestDep)) ?? defaultVersion;
|
|
1711
|
+
}
|
|
1712
|
+
debug(`Resolved ATA pins from ${FRAMER_PACKAGE_NAME}@${framerVersion ?? "latest"} (framer-motion ${this.pinnedTypeVersions["framer-motion"]}, react ${this.pinnedTypeVersions.react})`);
|
|
1713
|
+
} catch (err) {
|
|
1714
|
+
debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err);
|
|
1715
|
+
}
|
|
1716
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1717
|
+
}
|
|
1718
|
+
async resolvePackageJsonPins() {
|
|
1719
|
+
try {
|
|
1720
|
+
const pkgPath = path.join(this.projectDir, "package.json");
|
|
1721
|
+
const raw = await fs.readFile(pkgPath, "utf-8");
|
|
1722
|
+
const pkg = JSON.parse(raw);
|
|
1723
|
+
const allDeps = {
|
|
1724
|
+
...pkg.dependencies ?? {},
|
|
1725
|
+
...pkg.devDependencies ?? {}
|
|
1726
|
+
};
|
|
1727
|
+
for (const [name, range] of Object.entries(allDeps)) {
|
|
1728
|
+
const version = normalizePinnedVersion(range);
|
|
1729
|
+
if (version) this.pinnedTypeVersions[name] = version;
|
|
1730
|
+
}
|
|
1731
|
+
debug(`Resolved ${Object.keys(allDeps).length} package.json version pins`);
|
|
1732
|
+
} catch {
|
|
1733
|
+
warn("Could not read package.json for version pinning");
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Build an import statement with an optional `// types:` version pin for ATA.
|
|
1738
|
+
* Resolves the base package name for subpath imports (e.g., "framer-motion/dist" -> "framer-motion").
|
|
1739
|
+
*/
|
|
1740
|
+
pinImport(name) {
|
|
1741
|
+
const base = getBasePackageName(name);
|
|
1742
|
+
const version = this.pinnedTypeVersions[base];
|
|
1743
|
+
if (version) return `import "${name}"; // types: ${version}`;
|
|
1744
|
+
return `import "${name}";`;
|
|
1602
1745
|
}
|
|
1603
1746
|
async writeTypeFile(receivedPath, code) {
|
|
1604
1747
|
const normalized = receivedPath.replace(/^\//, "");
|
|
@@ -1622,7 +1765,8 @@ var Installer = class {
|
|
|
1622
1765
|
const response = await fetch(`https://registry.npmjs.org/${pkgName}`);
|
|
1623
1766
|
if (!response.ok) return;
|
|
1624
1767
|
const npmData = await response.json();
|
|
1625
|
-
const
|
|
1768
|
+
const pinnedVersion = this.pinnedTypeVersions[pkgName];
|
|
1769
|
+
const version = pinnedVersion ? this.findMatchingVersion(Object.keys(npmData.versions ?? {}), pinnedVersion) : npmData["dist-tags"]?.latest;
|
|
1626
1770
|
if (!version || !npmData.versions?.[version]) return;
|
|
1627
1771
|
const pkg = npmData.versions[version];
|
|
1628
1772
|
if (pkg.exports) for (const key of Object.keys(pkg.exports)) pkg.exports[key] = fixExportTypes(pkg.exports[key]);
|
|
@@ -1630,6 +1774,17 @@ var Installer = class {
|
|
|
1630
1774
|
await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2));
|
|
1631
1775
|
} catch {}
|
|
1632
1776
|
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Find the best matching version from a list of available versions.
|
|
1779
|
+
* Supports exact versions ("18.2.0") — returns exact match if available.
|
|
1780
|
+
*/
|
|
1781
|
+
findMatchingVersion(versions, pinned) {
|
|
1782
|
+
if (versions.includes(pinned)) return pinned;
|
|
1783
|
+
const [major, minor] = pinned.split(".");
|
|
1784
|
+
const prefix = `${major}.${minor}.`;
|
|
1785
|
+
const matching = versions.filter((v) => v.startsWith(prefix));
|
|
1786
|
+
return matching.length > 0 ? matching[matching.length - 1] : void 0;
|
|
1787
|
+
}
|
|
1633
1788
|
async ensureTsConfig() {
|
|
1634
1789
|
const tsconfigPath = path.join(this.projectDir, "tsconfig.json");
|
|
1635
1790
|
try {
|
|
@@ -1735,61 +1890,24 @@ declare module "*.json"
|
|
|
1735
1890
|
await fs.writeFile(gitignorePath, content);
|
|
1736
1891
|
debug("Created .gitignore");
|
|
1737
1892
|
}
|
|
1738
|
-
async ensureReact18Types() {
|
|
1739
|
-
const reactTypesDir = path.join(this.projectDir, "node_modules/@types/react");
|
|
1740
|
-
const reactFiles = [
|
|
1741
|
-
"package.json",
|
|
1742
|
-
"index.d.ts",
|
|
1743
|
-
"global.d.ts",
|
|
1744
|
-
"jsx-runtime.d.ts",
|
|
1745
|
-
"jsx-dev-runtime.d.ts"
|
|
1746
|
-
];
|
|
1747
|
-
if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) debug("📦 React types (from cache)");
|
|
1748
|
-
else {
|
|
1749
|
-
debug("Downloading React 18 types...");
|
|
1750
|
-
await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
|
|
1751
|
-
}
|
|
1752
|
-
const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
|
|
1753
|
-
const reactDomFiles = [
|
|
1754
|
-
"package.json",
|
|
1755
|
-
"index.d.ts",
|
|
1756
|
-
"client.d.ts"
|
|
1757
|
-
];
|
|
1758
|
-
if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) debug("📦 React DOM types (from cache)");
|
|
1759
|
-
else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
|
|
1760
|
-
}
|
|
1761
|
-
async hasTypePackage(destinationDir, version, files) {
|
|
1762
|
-
try {
|
|
1763
|
-
const pkgJsonPath = path.join(destinationDir, "package.json");
|
|
1764
|
-
const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
|
|
1765
|
-
if (JSON.parse(pkgJson).version !== version) return false;
|
|
1766
|
-
for (const file of files) {
|
|
1767
|
-
if (file === "package.json") continue;
|
|
1768
|
-
await fs.access(path.join(destinationDir, file));
|
|
1769
|
-
}
|
|
1770
|
-
return true;
|
|
1771
|
-
} catch {
|
|
1772
|
-
return false;
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
async downloadTypePackage(pkgName, version, destinationDir, files) {
|
|
1776
|
-
const baseUrl = `https://unpkg.com/${pkgName}@${version}`;
|
|
1777
|
-
await fs.mkdir(destinationDir, { recursive: true });
|
|
1778
|
-
await Promise.all(files.map(async (file) => {
|
|
1779
|
-
const destination = path.join(destinationDir, file);
|
|
1780
|
-
try {
|
|
1781
|
-
await fs.access(destination);
|
|
1782
|
-
return;
|
|
1783
|
-
} catch {}
|
|
1784
|
-
try {
|
|
1785
|
-
const response = await fetch(`${baseUrl}/${file}`);
|
|
1786
|
-
if (!response.ok) return;
|
|
1787
|
-
const content = await response.text();
|
|
1788
|
-
await fs.writeFile(destination, content);
|
|
1789
|
-
} catch {}
|
|
1790
|
-
}));
|
|
1791
|
-
}
|
|
1792
1893
|
};
|
|
1894
|
+
function getManifestDependencyVersion(manifest, packageName) {
|
|
1895
|
+
return manifest.peerDependencies?.[packageName] ?? manifest.dependencies?.[packageName];
|
|
1896
|
+
}
|
|
1897
|
+
function getBasePackageName(packageName) {
|
|
1898
|
+
const parts = packageName.split("/");
|
|
1899
|
+
if (packageName.startsWith("@")) return parts.length >= 2 ? parts.slice(0, 2).join("/") : packageName;
|
|
1900
|
+
return parts[0] ?? packageName;
|
|
1901
|
+
}
|
|
1902
|
+
function normalizePinnedVersion(version) {
|
|
1903
|
+
if (!version) return void 0;
|
|
1904
|
+
return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(version)?.[0];
|
|
1905
|
+
}
|
|
1906
|
+
async function fetchNpmPackageManifest(packageName) {
|
|
1907
|
+
const response = await fetchWithRetry(`https://registry.npmjs.org/${packageName}/latest`);
|
|
1908
|
+
if (!response.ok) throw new Error(`Failed to fetch ${packageName} manifest: ${response.status}`);
|
|
1909
|
+
return await response.json();
|
|
1910
|
+
}
|
|
1793
1911
|
/**
|
|
1794
1912
|
* Transform package.json exports to include .d.ts type paths
|
|
1795
1913
|
*/
|
|
@@ -1850,172 +1968,247 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
|
1850
1968
|
}
|
|
1851
1969
|
|
|
1852
1970
|
//#endregion
|
|
1853
|
-
//#region src/
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
this.name = "PluginDisconnectedError";
|
|
1858
|
-
}
|
|
1859
|
-
};
|
|
1860
|
-
var PluginUserPromptCoordinator = class {
|
|
1861
|
-
pendingActions = /* @__PURE__ */ new Map();
|
|
1862
|
-
/**
|
|
1863
|
-
* Register a pending action and return a typed promise
|
|
1864
|
-
*/
|
|
1865
|
-
awaitAction(actionId, description) {
|
|
1866
|
-
return new Promise((resolve, reject) => {
|
|
1867
|
-
this.pendingActions.set(actionId, {
|
|
1868
|
-
resolve,
|
|
1869
|
-
reject
|
|
1870
|
-
});
|
|
1871
|
-
debug(`Awaiting ${description}: ${actionId}`);
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
/**
|
|
1875
|
-
* Sends the delete request to the plugin and awaits the user's decision.
|
|
1876
|
-
* Returns the list of fileNames that were confirmed for deletion.
|
|
1877
|
-
*/
|
|
1878
|
-
async requestDeleteDecision(socket, { fileNames, requireConfirmation }) {
|
|
1879
|
-
if (!socket) throw new Error("Cannot request delete decision: plugin not connected");
|
|
1880
|
-
if (fileNames.length === 0) return [];
|
|
1881
|
-
if (requireConfirmation) {
|
|
1882
|
-
const confirmationPromises = fileNames.map((fileName) => this.awaitAction(`delete:${fileName}`, "delete confirmation").then((confirmed) => confirmed ? fileName : null).catch((err) => {
|
|
1883
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1884
|
-
debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
|
|
1885
|
-
return null;
|
|
1886
|
-
}
|
|
1887
|
-
throw err;
|
|
1888
|
-
}));
|
|
1889
|
-
await sendMessage(socket, {
|
|
1890
|
-
type: "file-delete",
|
|
1891
|
-
fileNames,
|
|
1892
|
-
requireConfirmation: true
|
|
1893
|
-
});
|
|
1894
|
-
return (await Promise.all(confirmationPromises)).filter((name) => name !== null);
|
|
1895
|
-
}
|
|
1896
|
-
await sendMessage(socket, {
|
|
1897
|
-
type: "file-delete",
|
|
1898
|
-
fileNames,
|
|
1899
|
-
requireConfirmation: false
|
|
1900
|
-
});
|
|
1901
|
-
return fileNames;
|
|
1902
|
-
}
|
|
1903
|
-
/**
|
|
1904
|
-
* Sends conflicts to the plugin and awaits user resolutions
|
|
1905
|
-
*/
|
|
1906
|
-
async requestConflictDecisions(socket, conflicts) {
|
|
1907
|
-
if (!socket) throw new Error("Cannot request conflict decision: plugin not connected");
|
|
1908
|
-
if (conflicts.length === 0) return /* @__PURE__ */ new Map();
|
|
1909
|
-
const pending = conflicts.map((conflict) => ({
|
|
1910
|
-
fileName: conflict.fileName,
|
|
1911
|
-
promise: this.awaitAction(`conflict:${conflict.fileName}`, "conflict resolution")
|
|
1912
|
-
}));
|
|
1913
|
-
await sendMessage(socket, {
|
|
1914
|
-
type: "conflicts-detected",
|
|
1915
|
-
conflicts
|
|
1916
|
-
});
|
|
1917
|
-
try {
|
|
1918
|
-
const results = await Promise.all(pending.map(async ({ fileName, promise }) => [fileName, await promise]));
|
|
1919
|
-
return new Map(results);
|
|
1920
|
-
} catch (err) {
|
|
1921
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1922
|
-
debug("Plugin disconnected while awaiting conflict decisions");
|
|
1923
|
-
return /* @__PURE__ */ new Map();
|
|
1924
|
-
}
|
|
1925
|
-
throw err;
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
/**
|
|
1929
|
-
* Handle incoming confirmation response
|
|
1930
|
-
*/
|
|
1931
|
-
handleConfirmation(actionId, value) {
|
|
1932
|
-
const pending = this.pendingActions.get(actionId);
|
|
1933
|
-
if (!pending) {
|
|
1934
|
-
debug(`Unexpected confirmation for ${actionId}`);
|
|
1935
|
-
return false;
|
|
1936
|
-
}
|
|
1937
|
-
this.pendingActions.delete(actionId);
|
|
1938
|
-
pending.resolve(value);
|
|
1939
|
-
debug(`Confirmed: ${actionId}`);
|
|
1940
|
-
return true;
|
|
1941
|
-
}
|
|
1942
|
-
/**
|
|
1943
|
-
* Cleanup all pending actions (e.g., on disconnect)
|
|
1944
|
-
*/
|
|
1945
|
-
cleanup() {
|
|
1946
|
-
for (const [actionId, pending] of this.pendingActions.entries()) {
|
|
1947
|
-
pending.reject(new PluginDisconnectedError());
|
|
1948
|
-
debug(`Cancelled pending action: ${actionId}`);
|
|
1949
|
-
}
|
|
1950
|
-
this.pendingActions.clear();
|
|
1951
|
-
}
|
|
1952
|
-
};
|
|
1953
|
-
|
|
1954
|
-
//#endregion
|
|
1955
|
-
//#region src/helpers/sync-validator.ts
|
|
1971
|
+
//#region src/utils/project.ts
|
|
1972
|
+
function isPlainObject(value) {
|
|
1973
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1974
|
+
}
|
|
1956
1975
|
/**
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
* During watching mode, we trust remote changes and apply them immediately.
|
|
1960
|
-
* During snapshot_processing, we queue them for later (to avoid race conditions).
|
|
1961
|
-
*
|
|
1962
|
-
* Note: This is for INCOMING changes from remote. Local changes (from watcher)
|
|
1963
|
-
* are handled separately and always sent during watching mode.
|
|
1976
|
+
* Reads package.json, migrates legacy top-level Code Link fields into `codeLink`,
|
|
1977
|
+
* and persists when anything changed.
|
|
1964
1978
|
*/
|
|
1965
|
-
function
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1979
|
+
async function readAndMigratePackageJson(packageJsonPath) {
|
|
1980
|
+
try {
|
|
1981
|
+
const raw = await fs.readFile(packageJsonPath, "utf-8");
|
|
1982
|
+
const parsed = JSON.parse(raw);
|
|
1983
|
+
if (!isPlainObject(parsed)) return null;
|
|
1984
|
+
if (!("shortProjectHash" in parsed || "framerProjectName" in parsed || "codeLinkNpmStrategy" in parsed)) return parsed;
|
|
1985
|
+
const existing = parsed.codeLink;
|
|
1986
|
+
const base = isPlainObject(existing) ? { ...existing } : {};
|
|
1987
|
+
if (parsed.shortProjectHash !== void 0 && base.shortProjectHash === void 0) base.shortProjectHash = parsed.shortProjectHash;
|
|
1988
|
+
if (parsed.framerProjectName !== void 0 && base.framerProjectName === void 0) base.framerProjectName = parsed.framerProjectName;
|
|
1989
|
+
if (parsed.codeLinkNpmStrategy !== void 0 && base.npmStrategy === void 0) base.npmStrategy = parsed.codeLinkNpmStrategy;
|
|
1990
|
+
const next = { ...parsed };
|
|
1991
|
+
delete next.shortProjectHash;
|
|
1992
|
+
delete next.framerProjectName;
|
|
1993
|
+
delete next.codeLinkNpmStrategy;
|
|
1994
|
+
next.codeLink = base;
|
|
1995
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(next, null, 4));
|
|
1996
|
+
return next;
|
|
1997
|
+
} catch {
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
function getShortProjectHashFromPackage(pkg) {
|
|
2002
|
+
const short = pkg.codeLink?.shortProjectHash;
|
|
2003
|
+
return typeof short === "string" ? short : null;
|
|
2004
|
+
}
|
|
2005
|
+
function toPackageName(name) {
|
|
2006
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2007
|
+
}
|
|
2008
|
+
function toDirectoryName(name) {
|
|
2009
|
+
return name.replace(/[^a-zA-Z0-9 -]/g, "-").trim().replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2010
|
+
}
|
|
2011
|
+
async function getProjectHashFromCwd() {
|
|
2012
|
+
const result = await readAndMigratePackageJson(path.join(process.cwd(), "package.json"));
|
|
2013
|
+
if (!result) return null;
|
|
2014
|
+
return getShortProjectHashFromPackage(result);
|
|
2015
|
+
}
|
|
2016
|
+
async function findOrCreateProjectDirectory(options) {
|
|
2017
|
+
const { projectHash, projectName, explicitDirectory, baseDirectory } = options;
|
|
2018
|
+
if (explicitDirectory) {
|
|
2019
|
+
const resolved = path.resolve(explicitDirectory);
|
|
2020
|
+
await fs.mkdir(path.join(resolved, "files"), { recursive: true });
|
|
1975
2021
|
return {
|
|
1976
|
-
|
|
1977
|
-
|
|
2022
|
+
directory: resolved,
|
|
2023
|
+
created: false
|
|
1978
2024
|
};
|
|
1979
2025
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2026
|
+
const cwd = baseDirectory ?? process.cwd();
|
|
2027
|
+
const existing = await findExistingProjectDirectory(cwd, projectHash);
|
|
2028
|
+
if (existing) return {
|
|
2029
|
+
directory: existing,
|
|
2030
|
+
created: false
|
|
2031
|
+
};
|
|
2032
|
+
if (!projectName) throw new Error("Failed to get Project name. Pass --name <project name>.");
|
|
2033
|
+
const directoryName = toDirectoryName(projectName);
|
|
2034
|
+
const pkgName = toPackageName(projectName);
|
|
2035
|
+
const shortId = shortProjectHash(projectHash);
|
|
2036
|
+
const { directory: projectDirectory, nameCollision } = await findAvailableDirectory(cwd, directoryName || `project-${shortId}`, shortId);
|
|
2037
|
+
await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true });
|
|
2038
|
+
const pkg = {
|
|
2039
|
+
name: pkgName || shortId,
|
|
2040
|
+
version: "1.0.0",
|
|
2041
|
+
private: true,
|
|
2042
|
+
codeLink: {
|
|
2043
|
+
shortProjectHash: shortId,
|
|
2044
|
+
framerProjectName: projectName
|
|
2045
|
+
}
|
|
1983
2046
|
};
|
|
2047
|
+
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 4));
|
|
1984
2048
|
return {
|
|
1985
|
-
|
|
1986
|
-
|
|
2049
|
+
directory: projectDirectory,
|
|
2050
|
+
created: true,
|
|
2051
|
+
nameCollision
|
|
1987
2052
|
};
|
|
1988
2053
|
}
|
|
1989
|
-
|
|
1990
|
-
//#endregion
|
|
1991
|
-
//#region src/utils/node-paths.ts
|
|
1992
|
-
/**
|
|
1993
|
-
* Path manipulation utilities
|
|
1994
|
-
*/
|
|
1995
2054
|
/**
|
|
1996
|
-
*
|
|
2055
|
+
* Returns a directory path that doesn't collide with an existing project.
|
|
2056
|
+
* Tries the bare name first, falls back to name-{shortId} if taken.
|
|
1997
2057
|
*/
|
|
1998
|
-
function
|
|
1999
|
-
|
|
2058
|
+
async function findAvailableDirectory(baseDir, name, shortId) {
|
|
2059
|
+
const candidate = path.join(baseDir, name);
|
|
2060
|
+
try {
|
|
2061
|
+
await fs.access(candidate);
|
|
2062
|
+
return {
|
|
2063
|
+
directory: path.join(baseDir, `${name}-${shortId}`),
|
|
2064
|
+
nameCollision: true
|
|
2065
|
+
};
|
|
2066
|
+
} catch {
|
|
2067
|
+
return {
|
|
2068
|
+
directory: candidate,
|
|
2069
|
+
nameCollision: false
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
async function findExistingProjectDirectory(baseDirectory, projectHash) {
|
|
2074
|
+
if (await matchesProject(path.join(baseDirectory, "package.json"), projectHash)) return baseDirectory;
|
|
2075
|
+
const entries = await fs.readdir(baseDirectory, { withFileTypes: true });
|
|
2076
|
+
for (const entry of entries) {
|
|
2077
|
+
if (!entry.isDirectory()) continue;
|
|
2078
|
+
const directory = path.join(baseDirectory, entry.name);
|
|
2079
|
+
if (await matchesProject(path.join(directory, "package.json"), projectHash)) return directory;
|
|
2080
|
+
}
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
async function matchesProject(packageJsonPath, projectHash) {
|
|
2084
|
+
try {
|
|
2085
|
+
const pkg = await readAndMigratePackageJson(packageJsonPath);
|
|
2086
|
+
if (!pkg) return false;
|
|
2087
|
+
const inputShort = shortProjectHash(projectHash);
|
|
2088
|
+
return getShortProjectHashFromPackage(pkg) === inputShort;
|
|
2089
|
+
} catch {
|
|
2090
|
+
return false;
|
|
2091
|
+
}
|
|
2000
2092
|
}
|
|
2001
2093
|
|
|
2002
2094
|
//#endregion
|
|
2003
|
-
//#region src/helpers/
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
matchingKey = key;
|
|
2095
|
+
//#region src/helpers/npm-strategy.ts
|
|
2096
|
+
const CONFIG_FIELD = "codeLink.npmStrategy";
|
|
2097
|
+
const LOCKFILES = [
|
|
2098
|
+
"yarn.lock",
|
|
2099
|
+
"pnpm-lock.yaml",
|
|
2100
|
+
"package-lock.json",
|
|
2101
|
+
"bun.lockb"
|
|
2102
|
+
];
|
|
2103
|
+
async function resolveNpmStrategy(config, projectDir) {
|
|
2104
|
+
if (config.npmStrategy) {
|
|
2105
|
+
debug(`Using npm strategy from CLI flag: ${config.npmStrategy}`);
|
|
2106
|
+
return config.npmStrategy;
|
|
2016
2107
|
}
|
|
2017
|
-
|
|
2018
|
-
|
|
2108
|
+
const packageJsonStrategy = await readPackageJsonStrategy(projectDir);
|
|
2109
|
+
if (packageJsonStrategy) {
|
|
2110
|
+
debug(`Using npm strategy from package.json ${CONFIG_FIELD}: ${packageJsonStrategy}`);
|
|
2111
|
+
return packageJsonStrategy;
|
|
2112
|
+
}
|
|
2113
|
+
const detectedLockfile = await detectLockfile(projectDir);
|
|
2114
|
+
if (detectedLockfile) {
|
|
2115
|
+
debug(`Using npm strategy package-manager from ${detectedLockfile}`);
|
|
2116
|
+
return "package-manager";
|
|
2117
|
+
}
|
|
2118
|
+
debug("Using default npm strategy: none");
|
|
2119
|
+
return "none";
|
|
2120
|
+
}
|
|
2121
|
+
async function readPackageJsonStrategy(projectDir) {
|
|
2122
|
+
try {
|
|
2123
|
+
const parsed = await readAndMigratePackageJson(path.join(projectDir, "package.json"));
|
|
2124
|
+
if (!parsed) return null;
|
|
2125
|
+
const strategy = parsed.codeLink?.npmStrategy;
|
|
2126
|
+
if (strategy === void 0) return null;
|
|
2127
|
+
if (isNpmStrategy(strategy)) return strategy;
|
|
2128
|
+
warn(`Ignoring invalid package.json ${CONFIG_FIELD}: ${String(strategy)}`);
|
|
2129
|
+
return null;
|
|
2130
|
+
} catch {
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
async function detectLockfile(projectDir) {
|
|
2135
|
+
for (const fileName of LOCKFILES) try {
|
|
2136
|
+
await fs.access(path.join(projectDir, fileName));
|
|
2137
|
+
return fileName;
|
|
2138
|
+
} catch {}
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
function isNpmStrategy(value) {
|
|
2142
|
+
return value === "none" || value === "acquire-types" || value === "package-manager";
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
//#endregion
|
|
2146
|
+
//#region src/scheduler.ts
|
|
2147
|
+
const TIMINGS = {
|
|
2148
|
+
disconnectNotice: 4e3,
|
|
2149
|
+
expectedDeleteEchoExpiry: 5e3,
|
|
2150
|
+
renameBuffer: 100,
|
|
2151
|
+
sanitizationEchoExpiry: 300
|
|
2152
|
+
};
|
|
2153
|
+
function timerId(task, key) {
|
|
2154
|
+
return key !== void 0 ? `${task}:${key}` : task;
|
|
2155
|
+
}
|
|
2156
|
+
function createScheduler() {
|
|
2157
|
+
const timers = /* @__PURE__ */ new Map();
|
|
2158
|
+
return {
|
|
2159
|
+
after(task, delayMs, fn, key) {
|
|
2160
|
+
const id = timerId(task, key);
|
|
2161
|
+
const existing = timers.get(id);
|
|
2162
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
2163
|
+
const handle = setTimeout(() => {
|
|
2164
|
+
timers.delete(id);
|
|
2165
|
+
fn();
|
|
2166
|
+
}, delayMs);
|
|
2167
|
+
timers.set(id, handle);
|
|
2168
|
+
},
|
|
2169
|
+
cancel(task, key) {
|
|
2170
|
+
const id = timerId(task, key);
|
|
2171
|
+
const handle = timers.get(id);
|
|
2172
|
+
if (handle !== void 0) {
|
|
2173
|
+
clearTimeout(handle);
|
|
2174
|
+
timers.delete(id);
|
|
2175
|
+
}
|
|
2176
|
+
},
|
|
2177
|
+
cancelAll() {
|
|
2178
|
+
for (const handle of timers.values()) clearTimeout(handle);
|
|
2179
|
+
timers.clear();
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
//#endregion
|
|
2185
|
+
//#region src/utils/node-paths.ts
|
|
2186
|
+
/**
|
|
2187
|
+
* Path manipulation utilities
|
|
2188
|
+
*/
|
|
2189
|
+
/**
|
|
2190
|
+
* Gets a relative path from the project directory
|
|
2191
|
+
*/
|
|
2192
|
+
function getRelativePath(projectDir, absolutePath) {
|
|
2193
|
+
return path.relative(projectDir, absolutePath);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/helpers/watcher.ts
|
|
2198
|
+
/**
|
|
2199
|
+
* File watcher helper
|
|
2200
|
+
*
|
|
2201
|
+
* Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
|
|
2202
|
+
*/
|
|
2203
|
+
function findUniqueHashMatch(pendingItems, contentHash) {
|
|
2204
|
+
let matchingKey;
|
|
2205
|
+
for (const [key, pending] of pendingItems) {
|
|
2206
|
+
if (pending.contentHash !== contentHash) continue;
|
|
2207
|
+
if (matchingKey !== void 0) return;
|
|
2208
|
+
matchingKey = key;
|
|
2209
|
+
}
|
|
2210
|
+
return matchingKey;
|
|
2211
|
+
}
|
|
2019
2212
|
function matchPendingAddForDelete(contentHash, pendingAdds) {
|
|
2020
2213
|
if (!contentHash) return null;
|
|
2021
2214
|
const matchingAddKey = findUniqueHashMatch(pendingAdds, contentHash);
|
|
@@ -2042,6 +2235,7 @@ function matchPendingDeleteForAdd(contentHash, pendingDeletes) {
|
|
|
2042
2235
|
*/
|
|
2043
2236
|
function initWatcher(filesDir) {
|
|
2044
2237
|
const handlers = [];
|
|
2238
|
+
const scheduler = createScheduler();
|
|
2045
2239
|
const contentHashCache = /* @__PURE__ */ new Map();
|
|
2046
2240
|
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
2047
2241
|
const pendingAdds = /* @__PURE__ */ new Map();
|
|
@@ -2091,10 +2285,10 @@ function initWatcher(filesDir) {
|
|
|
2091
2285
|
effectiveAbsolutePath = newAbsolutePath;
|
|
2092
2286
|
recentSanitizations.add(rawRelativePath);
|
|
2093
2287
|
recentSanitizations.add(nextRelativePath);
|
|
2094
|
-
|
|
2288
|
+
scheduler.after("sanitizationEchoExpiry", TIMINGS.sanitizationEchoExpiry, () => {
|
|
2095
2289
|
recentSanitizations.delete(rawRelativePath);
|
|
2096
2290
|
recentSanitizations.delete(nextRelativePath);
|
|
2097
|
-
},
|
|
2291
|
+
}, `${rawRelativePath}\0${nextRelativePath}`);
|
|
2098
2292
|
} catch (err) {
|
|
2099
2293
|
warn(`Failed to rename ${rawRelativePath}`, err);
|
|
2100
2294
|
return {
|
|
@@ -2123,7 +2317,7 @@ function initWatcher(filesDir) {
|
|
|
2123
2317
|
contentHashCache.delete(relativePath);
|
|
2124
2318
|
const samePathPendingAdd = pendingAdds.get(relativePath);
|
|
2125
2319
|
if (samePathPendingAdd) {
|
|
2126
|
-
|
|
2320
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2127
2321
|
pendingAdds.delete(relativePath);
|
|
2128
2322
|
try {
|
|
2129
2323
|
const latestContent = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
@@ -2145,7 +2339,7 @@ function initWatcher(filesDir) {
|
|
|
2145
2339
|
}
|
|
2146
2340
|
const matchedAdd = matchPendingAddForDelete(lastHash, pendingAdds);
|
|
2147
2341
|
if (matchedAdd) {
|
|
2148
|
-
|
|
2342
|
+
scheduler.cancel("renameBuffer", matchedAdd.key);
|
|
2149
2343
|
pendingAdds.delete(matchedAdd.key);
|
|
2150
2344
|
dispatchEvent({
|
|
2151
2345
|
kind: "rename",
|
|
@@ -2156,17 +2350,16 @@ function initWatcher(filesDir) {
|
|
|
2156
2350
|
return;
|
|
2157
2351
|
}
|
|
2158
2352
|
if (lastHash) {
|
|
2159
|
-
|
|
2353
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2160
2354
|
pendingDeletes.delete(relativePath);
|
|
2161
2355
|
dispatchEvent({
|
|
2162
2356
|
kind: "delete",
|
|
2163
2357
|
relativePath
|
|
2164
2358
|
});
|
|
2165
|
-
},
|
|
2359
|
+
}, relativePath);
|
|
2166
2360
|
pendingDeletes.set(relativePath, {
|
|
2167
2361
|
relativePath,
|
|
2168
|
-
contentHash: lastHash
|
|
2169
|
-
timer
|
|
2362
|
+
contentHash: lastHash
|
|
2170
2363
|
});
|
|
2171
2364
|
} else dispatchEvent({
|
|
2172
2365
|
kind: "delete",
|
|
@@ -2185,9 +2378,8 @@ function initWatcher(filesDir) {
|
|
|
2185
2378
|
const contentHash = hashFileContent(content);
|
|
2186
2379
|
contentHashCache.set(relativePath, contentHash);
|
|
2187
2380
|
if (kind === "add") {
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
clearTimeout(samePathPendingDelete.timer);
|
|
2381
|
+
if (pendingDeletes.get(relativePath)) {
|
|
2382
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2191
2383
|
pendingDeletes.delete(relativePath);
|
|
2192
2384
|
dispatchEvent({
|
|
2193
2385
|
kind: "change",
|
|
@@ -2198,7 +2390,7 @@ function initWatcher(filesDir) {
|
|
|
2198
2390
|
}
|
|
2199
2391
|
const matchedDelete = matchPendingDeleteForAdd(contentHash, pendingDeletes);
|
|
2200
2392
|
if (matchedDelete) {
|
|
2201
|
-
|
|
2393
|
+
scheduler.cancel("renameBuffer", matchedDelete.key);
|
|
2202
2394
|
pendingDeletes.delete(matchedDelete.key);
|
|
2203
2395
|
dispatchEvent({
|
|
2204
2396
|
kind: "rename",
|
|
@@ -2209,28 +2401,26 @@ function initWatcher(filesDir) {
|
|
|
2209
2401
|
return;
|
|
2210
2402
|
}
|
|
2211
2403
|
const existingPendingAdd = pendingAdds.get(relativePath);
|
|
2212
|
-
if (existingPendingAdd)
|
|
2404
|
+
if (existingPendingAdd) scheduler.cancel("renameBuffer", relativePath);
|
|
2213
2405
|
const retainedPreviousContentHash = existingPendingAdd ? existingPendingAdd.previousContentHash : previousContentHash;
|
|
2214
|
-
|
|
2406
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2215
2407
|
pendingAdds.delete(relativePath);
|
|
2216
2408
|
dispatchEvent({
|
|
2217
2409
|
kind: "add",
|
|
2218
2410
|
relativePath,
|
|
2219
2411
|
content
|
|
2220
2412
|
});
|
|
2221
|
-
},
|
|
2413
|
+
}, relativePath);
|
|
2222
2414
|
pendingAdds.set(relativePath, {
|
|
2223
2415
|
relativePath,
|
|
2224
2416
|
contentHash,
|
|
2225
2417
|
content,
|
|
2226
|
-
timer,
|
|
2227
2418
|
previousContentHash: retainedPreviousContentHash
|
|
2228
2419
|
});
|
|
2229
2420
|
return;
|
|
2230
2421
|
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
clearTimeout(pendingAdd.timer);
|
|
2422
|
+
if (pendingAdds.get(relativePath)) {
|
|
2423
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2234
2424
|
pendingAdds.delete(relativePath);
|
|
2235
2425
|
dispatchEvent({
|
|
2236
2426
|
kind: "add",
|
|
@@ -2259,8 +2449,7 @@ function initWatcher(filesDir) {
|
|
|
2259
2449
|
handlers.push(handler);
|
|
2260
2450
|
},
|
|
2261
2451
|
async close() {
|
|
2262
|
-
|
|
2263
|
-
for (const pending of pendingAdds.values()) clearTimeout(pending.timer);
|
|
2452
|
+
scheduler.cancelAll();
|
|
2264
2453
|
pendingDeletes.clear();
|
|
2265
2454
|
pendingAdds.clear();
|
|
2266
2455
|
contentHashCache.clear();
|
|
@@ -2354,151 +2543,391 @@ var FileMetadataCache = class {
|
|
|
2354
2543
|
};
|
|
2355
2544
|
|
|
2356
2545
|
//#endregion
|
|
2357
|
-
//#region src/
|
|
2546
|
+
//#region src/sync-memory.ts
|
|
2358
2547
|
/**
|
|
2359
|
-
*
|
|
2548
|
+
* SyncMemory owns file-level sync truth.
|
|
2360
2549
|
*
|
|
2361
|
-
*
|
|
2362
|
-
*
|
|
2550
|
+
* If a race depends on path normalization, content echoes, expected delete echoes,
|
|
2551
|
+
* or agreed metadata, it belongs here. Controller/apply code should call these
|
|
2552
|
+
* named operations instead of touching the underlying maps directly.
|
|
2363
2553
|
*/
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
*/
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
return {
|
|
2372
|
-
remember(filePath, content) {
|
|
2373
|
-
const hash = hashFileContent(content);
|
|
2374
|
-
hashes.set(keyFor(filePath), hash);
|
|
2375
|
-
},
|
|
2376
|
-
shouldSkip(filePath, content) {
|
|
2377
|
-
const currentHash = hashFileContent(content);
|
|
2378
|
-
return hashes.get(keyFor(filePath)) === currentHash;
|
|
2379
|
-
},
|
|
2380
|
-
forget(filePath) {
|
|
2381
|
-
hashes.delete(keyFor(filePath));
|
|
2382
|
-
},
|
|
2383
|
-
clear() {
|
|
2384
|
-
hashes.clear();
|
|
2385
|
-
},
|
|
2386
|
-
markDelete(filePath) {
|
|
2387
|
-
const key = keyFor(filePath);
|
|
2388
|
-
const existingTimer = pendingDeletes.get(key);
|
|
2389
|
-
if (existingTimer) clearTimeout(existingTimer);
|
|
2390
|
-
const timeout = setTimeout(() => {
|
|
2391
|
-
pendingDeletes.delete(key);
|
|
2392
|
-
}, 5e3);
|
|
2393
|
-
pendingDeletes.set(key, timeout);
|
|
2394
|
-
},
|
|
2395
|
-
shouldSkipDelete(filePath) {
|
|
2396
|
-
return pendingDeletes.has(keyFor(filePath));
|
|
2397
|
-
},
|
|
2398
|
-
clearDelete(filePath) {
|
|
2399
|
-
const key = keyFor(filePath);
|
|
2400
|
-
const timeout = pendingDeletes.get(key);
|
|
2401
|
-
if (timeout) clearTimeout(timeout);
|
|
2402
|
-
pendingDeletes.delete(key);
|
|
2403
|
-
}
|
|
2404
|
-
};
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
//#endregion
|
|
2408
|
-
//#region src/utils/project.ts
|
|
2409
|
-
function toPackageName(name) {
|
|
2410
|
-
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2411
|
-
}
|
|
2412
|
-
function toDirectoryName(name) {
|
|
2413
|
-
return name.replace(/[^a-zA-Z0-9 -]/g, "-").trim().replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2414
|
-
}
|
|
2415
|
-
async function getProjectHashFromCwd() {
|
|
2416
|
-
try {
|
|
2417
|
-
const packageJsonPath = path.join(process.cwd(), "package.json");
|
|
2418
|
-
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
2419
|
-
return JSON.parse(content).shortProjectHash ?? null;
|
|
2420
|
-
} catch {
|
|
2421
|
-
return null;
|
|
2554
|
+
var SyncMemory = class {
|
|
2555
|
+
metadata = new FileMetadataCache();
|
|
2556
|
+
contentEchoes = /* @__PURE__ */ new Map();
|
|
2557
|
+
expectedDeleteEchoes = /* @__PURE__ */ new Set();
|
|
2558
|
+
scheduler = createScheduler();
|
|
2559
|
+
normalizePath(filePath) {
|
|
2560
|
+
return normalizeCodeFilePathWithExtension(filePath);
|
|
2422
2561
|
}
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2562
|
+
metadataFor(filePath) {
|
|
2563
|
+
return this.metadata.get(filePath);
|
|
2564
|
+
}
|
|
2565
|
+
persistedSnapshot() {
|
|
2566
|
+
return this.metadata.getPersistedState();
|
|
2567
|
+
}
|
|
2568
|
+
recordSyncedContent(filePath, content, modifiedAt) {
|
|
2569
|
+
this.metadata.recordSyncedSnapshot(filePath, hashFileContent(content), modifiedAt);
|
|
2570
|
+
}
|
|
2571
|
+
recordSyncedDelete(filePath) {
|
|
2572
|
+
this.clearContentEcho(filePath);
|
|
2573
|
+
this.metadata.recordDelete(filePath);
|
|
2574
|
+
}
|
|
2575
|
+
matchesAgreedContent(filePath, content) {
|
|
2576
|
+
return this.metadataFor(filePath)?.lastSyncedHash === hashFileContent(content);
|
|
2577
|
+
}
|
|
2578
|
+
armContentEcho(filePath, content) {
|
|
2579
|
+
const path = this.normalizePath(filePath);
|
|
2580
|
+
this.contentEchoes.set(path, hashFileContent(content));
|
|
2429
2581
|
return {
|
|
2430
|
-
|
|
2431
|
-
|
|
2582
|
+
path,
|
|
2583
|
+
content
|
|
2432
2584
|
};
|
|
2433
2585
|
}
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2586
|
+
matchesContentEcho(filePath, content) {
|
|
2587
|
+
return this.contentEchoes.get(this.normalizePath(filePath)) === hashFileContent(content);
|
|
2588
|
+
}
|
|
2589
|
+
clearContentEcho(filePath) {
|
|
2590
|
+
this.contentEchoes.delete(this.normalizePath(filePath));
|
|
2591
|
+
}
|
|
2592
|
+
clearAllContentEchoes() {
|
|
2593
|
+
this.contentEchoes.clear();
|
|
2594
|
+
}
|
|
2595
|
+
isContentEcho(filePath, content) {
|
|
2596
|
+
return this.matchesAgreedContent(filePath, content) || this.matchesContentEcho(filePath, content);
|
|
2597
|
+
}
|
|
2598
|
+
commitWriteSuccess(prepared, modifiedAt) {
|
|
2599
|
+
this.recordSyncedContent(prepared.path, prepared.content, modifiedAt);
|
|
2600
|
+
}
|
|
2601
|
+
rollbackWriteFailure(prepared) {
|
|
2602
|
+
if (this.matchesContentEcho(prepared.path, prepared.content)) this.clearContentEcho(prepared.path);
|
|
2603
|
+
}
|
|
2604
|
+
armExpectedDeleteEcho(filePath) {
|
|
2605
|
+
const path = this.normalizePath(filePath);
|
|
2606
|
+
this.scheduler.cancel("expectedDeleteEchoExpiry", path);
|
|
2607
|
+
this.expectedDeleteEchoes.add(path);
|
|
2608
|
+
this.scheduler.after("expectedDeleteEchoExpiry", TIMINGS.expectedDeleteEchoExpiry, () => {
|
|
2609
|
+
this.expectedDeleteEchoes.delete(path);
|
|
2610
|
+
}, path);
|
|
2611
|
+
return { path };
|
|
2612
|
+
}
|
|
2613
|
+
matchesExpectedDeleteEcho(filePath) {
|
|
2614
|
+
return this.expectedDeleteEchoes.has(this.normalizePath(filePath));
|
|
2615
|
+
}
|
|
2616
|
+
clearExpectedDeleteEcho(filePath) {
|
|
2617
|
+
const path = this.normalizePath(filePath);
|
|
2618
|
+
this.scheduler.cancel("expectedDeleteEchoExpiry", path);
|
|
2619
|
+
this.expectedDeleteEchoes.delete(path);
|
|
2620
|
+
}
|
|
2621
|
+
commitDeleteSuccess(prepared) {
|
|
2622
|
+
this.clearContentEcho(prepared.path);
|
|
2623
|
+
this.recordSyncedDelete(prepared.path);
|
|
2624
|
+
}
|
|
2625
|
+
rollbackExpectedDeleteEcho(prepared) {
|
|
2626
|
+
this.clearExpectedDeleteEcho(prepared.path);
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2630
|
+
//#endregion
|
|
2631
|
+
//#region src/runtime.ts
|
|
2632
|
+
function sameSession(a, b) {
|
|
2633
|
+
return a.connectionId === b.connectionId && a.promptId === b.promptId;
|
|
2634
|
+
}
|
|
2635
|
+
function createPromptSession(connectionId) {
|
|
2636
|
+
return {
|
|
2637
|
+
connectionId,
|
|
2638
|
+
promptId: randomUUID()
|
|
2439
2639
|
};
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
private: true,
|
|
2450
|
-
shortProjectHash: shortId,
|
|
2451
|
-
framerProjectName: projectName
|
|
2640
|
+
}
|
|
2641
|
+
function conflictIsResolved(conflict) {
|
|
2642
|
+
return conflict.localContent === conflict.remoteContent;
|
|
2643
|
+
}
|
|
2644
|
+
function resolvedPromptConflict(conflict) {
|
|
2645
|
+
return {
|
|
2646
|
+
fileName: conflict.fileName,
|
|
2647
|
+
content: conflict.localContent,
|
|
2648
|
+
modifiedAt: conflict.remoteModifiedAt ?? conflict.localModifiedAt
|
|
2452
2649
|
};
|
|
2453
|
-
|
|
2650
|
+
}
|
|
2651
|
+
function normalizeConflict(filePath, conflict) {
|
|
2454
2652
|
return {
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
nameCollision
|
|
2653
|
+
...conflict,
|
|
2654
|
+
fileName: filePath(conflict.fileName)
|
|
2458
2655
|
};
|
|
2459
2656
|
}
|
|
2460
2657
|
/**
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
2658
|
+
* SyncRuntime owns lifecycle truth.
|
|
2659
|
+
*
|
|
2660
|
+
* Search this file and `sync-memory.ts` first for race-sensitive state.
|
|
2661
|
+
* Lifecycle state lives here; file-level sync facts live on `memory`.
|
|
2463
2662
|
*/
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2663
|
+
var SyncRuntime = class {
|
|
2664
|
+
memory = new SyncMemory();
|
|
2665
|
+
pendingRenameConfirmations = /* @__PURE__ */ new Map();
|
|
2666
|
+
disconnectScheduler = createScheduler();
|
|
2667
|
+
activeDeletePrompt = null;
|
|
2668
|
+
activeConflictPrompt = null;
|
|
2669
|
+
pendingSyncCompletionEvent = null;
|
|
2670
|
+
installer = null;
|
|
2671
|
+
connectionSeq = 0;
|
|
2672
|
+
activeConnectionId = 0;
|
|
2673
|
+
isShowingDisconnect = false;
|
|
2674
|
+
hadRecentDisconnect = false;
|
|
2675
|
+
lastEmittedStatus = null;
|
|
2676
|
+
workspaceState = {
|
|
2677
|
+
projectDir: null,
|
|
2678
|
+
filesDir: null,
|
|
2679
|
+
projectDirCreated: false
|
|
2680
|
+
};
|
|
2681
|
+
get workspace() {
|
|
2682
|
+
return this.workspaceState;
|
|
2683
|
+
}
|
|
2684
|
+
get metadata() {
|
|
2685
|
+
return this.memory.metadata;
|
|
2686
|
+
}
|
|
2687
|
+
configureWorkspace(projectDir, projectDirCreated) {
|
|
2688
|
+
this.workspaceState.projectDir = projectDir;
|
|
2689
|
+
this.workspaceState.filesDir = path.join(projectDir, "files");
|
|
2690
|
+
this.workspaceState.projectDirCreated = projectDirCreated;
|
|
2691
|
+
}
|
|
2692
|
+
mintConnectionId() {
|
|
2693
|
+
this.connectionSeq += 1;
|
|
2694
|
+
this.activeConnectionId = this.connectionSeq;
|
|
2695
|
+
return this.activeConnectionId;
|
|
2696
|
+
}
|
|
2697
|
+
get connectionId() {
|
|
2698
|
+
return this.activeConnectionId;
|
|
2699
|
+
}
|
|
2700
|
+
noteEmittedSyncStatus(status) {
|
|
2701
|
+
this.lastEmittedStatus = status;
|
|
2702
|
+
}
|
|
2703
|
+
clearEmittedSyncStatus() {
|
|
2704
|
+
this.lastEmittedStatus = null;
|
|
2705
|
+
}
|
|
2706
|
+
get lastEmittedSyncStatus() {
|
|
2707
|
+
return this.lastEmittedStatus;
|
|
2708
|
+
}
|
|
2709
|
+
disconnectUi = {
|
|
2710
|
+
scheduleNotice: (callback) => {
|
|
2711
|
+
this.disconnectScheduler.cancel("disconnectNotice");
|
|
2712
|
+
this.hadRecentDisconnect = true;
|
|
2713
|
+
this.isShowingDisconnect = false;
|
|
2714
|
+
this.disconnectScheduler.after("disconnectNotice", TIMINGS.disconnectNotice, () => {
|
|
2715
|
+
this.isShowingDisconnect = true;
|
|
2716
|
+
callback();
|
|
2717
|
+
});
|
|
2718
|
+
},
|
|
2719
|
+
cancelNotice: () => {
|
|
2720
|
+
this.disconnectScheduler.cancel("disconnectNotice");
|
|
2721
|
+
},
|
|
2722
|
+
didShowNotice: () => this.isShowingDisconnect,
|
|
2723
|
+
wasRecentlyDisconnected: () => this.hadRecentDisconnect,
|
|
2724
|
+
reset: () => {
|
|
2725
|
+
this.isShowingDisconnect = false;
|
|
2726
|
+
this.hadRecentDisconnect = false;
|
|
2727
|
+
}
|
|
2728
|
+
};
|
|
2729
|
+
getPendingRename(newPath) {
|
|
2730
|
+
return this.pendingRenameConfirmations.get(normalizeCodeFilePathWithExtension(newPath));
|
|
2731
|
+
}
|
|
2732
|
+
registerPendingRename(newPath, value) {
|
|
2733
|
+
this.pendingRenameConfirmations.set(normalizeCodeFilePathWithExtension(newPath), value);
|
|
2734
|
+
}
|
|
2735
|
+
completePendingRename(newPath) {
|
|
2736
|
+
this.pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(newPath));
|
|
2737
|
+
}
|
|
2738
|
+
clearPendingRenames() {
|
|
2739
|
+
this.pendingRenameConfirmations.clear();
|
|
2740
|
+
}
|
|
2741
|
+
startDeletePrompt(fileNames) {
|
|
2742
|
+
const prompt = this.activeDeletePrompt ?? {
|
|
2743
|
+
session: createPromptSession(this.connectionId),
|
|
2744
|
+
fileNames: /* @__PURE__ */ new Set()
|
|
2745
|
+
};
|
|
2746
|
+
const newNames = [];
|
|
2747
|
+
for (const fileName of fileNames) {
|
|
2748
|
+
const normalized = this.memory.normalizePath(fileName);
|
|
2749
|
+
if (prompt.fileNames.has(normalized)) continue;
|
|
2750
|
+
prompt.fileNames.add(normalized);
|
|
2751
|
+
newNames.push(normalized);
|
|
2752
|
+
}
|
|
2753
|
+
this.activeDeletePrompt = prompt;
|
|
2754
|
+
return newNames.length > 0 ? {
|
|
2755
|
+
session: prompt.session,
|
|
2756
|
+
fileNames: newNames
|
|
2757
|
+
} : null;
|
|
2758
|
+
}
|
|
2759
|
+
hasActiveDeletePrompt(session) {
|
|
2760
|
+
return this.activeDeletePrompt !== null && sameSession(this.activeDeletePrompt.session, session);
|
|
2761
|
+
}
|
|
2762
|
+
getDeletePromptFileNames(session, fileNames) {
|
|
2763
|
+
const prompt = this.activeDeletePrompt;
|
|
2764
|
+
if (!prompt || !sameSession(prompt.session, session)) return null;
|
|
2765
|
+
const active = (fileNames.length > 0 ? fileNames.map((fileName) => this.memory.normalizePath(fileName)) : [...prompt.fileNames.values()]).filter((fileName) => prompt.fileNames.has(fileName));
|
|
2766
|
+
return active.length > 0 ? active : null;
|
|
2767
|
+
}
|
|
2768
|
+
clearDeletePromptFiles(session, fileNames) {
|
|
2769
|
+
const prompt = this.activeDeletePrompt;
|
|
2770
|
+
if (!prompt || !sameSession(prompt.session, session)) return false;
|
|
2771
|
+
const requested = fileNames.length > 0 ? fileNames : [...prompt.fileNames.values()];
|
|
2772
|
+
for (const fileName of requested) prompt.fileNames.delete(this.memory.normalizePath(fileName));
|
|
2773
|
+
if (prompt.fileNames.size === 0) this.activeDeletePrompt = null;
|
|
2774
|
+
return true;
|
|
2775
|
+
}
|
|
2776
|
+
isActiveDeletePromptPath(filePath) {
|
|
2777
|
+
return this.activeDeletePrompt?.fileNames.has(this.memory.normalizePath(filePath)) ?? false;
|
|
2778
|
+
}
|
|
2779
|
+
hasAnyActivePrompt() {
|
|
2780
|
+
return this.activeDeletePrompt !== null || this.activeConflictPrompt !== null;
|
|
2781
|
+
}
|
|
2782
|
+
deferSyncComplete(syncComplete) {
|
|
2783
|
+
this.pendingSyncCompletionEvent = this.pendingSyncCompletionEvent === null ? syncComplete : {
|
|
2784
|
+
totalCount: this.pendingSyncCompletionEvent.totalCount + syncComplete.totalCount,
|
|
2785
|
+
updatedCount: this.pendingSyncCompletionEvent.updatedCount + syncComplete.updatedCount,
|
|
2786
|
+
unchangedCount: this.pendingSyncCompletionEvent.unchangedCount + syncComplete.unchangedCount
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Claims the pending sync-complete event and clears it when ready to fire.
|
|
2791
|
+
* - `ready`: payload is returned and the slot is cleared.
|
|
2792
|
+
* - `blocked`: payload remains pending until prompts clear.
|
|
2793
|
+
* - `empty`: nothing was pending.
|
|
2794
|
+
*/
|
|
2795
|
+
claimPendingSyncComplete() {
|
|
2796
|
+
if (this.pendingSyncCompletionEvent === null) return { status: "empty" };
|
|
2797
|
+
if (this.hasAnyActivePrompt()) return { status: "blocked" };
|
|
2798
|
+
const syncComplete = this.pendingSyncCompletionEvent;
|
|
2799
|
+
this.pendingSyncCompletionEvent = null;
|
|
2468
2800
|
return {
|
|
2469
|
-
|
|
2470
|
-
|
|
2801
|
+
status: "ready",
|
|
2802
|
+
payload: syncComplete
|
|
2471
2803
|
};
|
|
2472
|
-
}
|
|
2804
|
+
}
|
|
2805
|
+
invalidateDeletePromptPath(filePath) {
|
|
2806
|
+
const prompt = this.activeDeletePrompt;
|
|
2807
|
+
const normalized = this.memory.normalizePath(filePath);
|
|
2808
|
+
if (!prompt || !prompt.fileNames.has(normalized)) return { changed: false };
|
|
2809
|
+
prompt.fileNames.delete(normalized);
|
|
2810
|
+
const cleared = prompt.fileNames.size === 0;
|
|
2811
|
+
if (cleared) this.activeDeletePrompt = null;
|
|
2473
2812
|
return {
|
|
2474
|
-
|
|
2475
|
-
|
|
2813
|
+
changed: true,
|
|
2814
|
+
session: prompt.session,
|
|
2815
|
+
fileNames: [normalized],
|
|
2816
|
+
cleared
|
|
2476
2817
|
};
|
|
2477
2818
|
}
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
const
|
|
2485
|
-
|
|
2819
|
+
startOrUpdateConflictPrompt(conflicts) {
|
|
2820
|
+
if (conflicts.length === 0) return null;
|
|
2821
|
+
const prompt = this.activeConflictPrompt ?? {
|
|
2822
|
+
session: createPromptSession(this.connectionId),
|
|
2823
|
+
conflicts: /* @__PURE__ */ new Map()
|
|
2824
|
+
};
|
|
2825
|
+
for (const conflict of conflicts) {
|
|
2826
|
+
const normalized = normalizeConflict((filePath) => this.memory.normalizePath(filePath), conflict);
|
|
2827
|
+
if (conflictIsResolved(normalized)) prompt.conflicts.delete(normalized.fileName);
|
|
2828
|
+
else prompt.conflicts.set(normalized.fileName, normalized);
|
|
2829
|
+
}
|
|
2830
|
+
const nextConflicts = [...prompt.conflicts.values()];
|
|
2831
|
+
this.activeConflictPrompt = nextConflicts.length > 0 ? prompt : null;
|
|
2832
|
+
return nextConflicts.length > 0 ? {
|
|
2833
|
+
session: prompt.session,
|
|
2834
|
+
conflicts: nextConflicts
|
|
2835
|
+
} : null;
|
|
2486
2836
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
const inputShort = shortProjectHash(projectHash);
|
|
2494
|
-
return pkg.shortProjectHash === inputShort;
|
|
2495
|
-
} catch {
|
|
2496
|
-
return false;
|
|
2837
|
+
getActiveConflictPrompt() {
|
|
2838
|
+
const prompt = this.activeConflictPrompt;
|
|
2839
|
+
return prompt ? {
|
|
2840
|
+
session: prompt.session,
|
|
2841
|
+
conflicts: [...prompt.conflicts.values()]
|
|
2842
|
+
} : null;
|
|
2497
2843
|
}
|
|
2498
|
-
|
|
2844
|
+
getConflictPromptConflicts(session, fileNames) {
|
|
2845
|
+
const prompt = this.activeConflictPrompt;
|
|
2846
|
+
if (!prompt || !sameSession(prompt.session, session)) return null;
|
|
2847
|
+
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);
|
|
2848
|
+
return conflicts.length > 0 ? conflicts : null;
|
|
2849
|
+
}
|
|
2850
|
+
clearConflictPromptFiles(session, fileNames) {
|
|
2851
|
+
const prompt = this.activeConflictPrompt;
|
|
2852
|
+
if (!prompt || !sameSession(prompt.session, session)) return false;
|
|
2853
|
+
const requested = fileNames.length > 0 ? fileNames : [...prompt.conflicts.keys()];
|
|
2854
|
+
for (const fileName of requested) prompt.conflicts.delete(this.memory.normalizePath(fileName));
|
|
2855
|
+
if (prompt.conflicts.size === 0) this.activeConflictPrompt = null;
|
|
2856
|
+
return true;
|
|
2857
|
+
}
|
|
2858
|
+
isActiveConflictPath(filePath) {
|
|
2859
|
+
return this.activeConflictPrompt?.conflicts.has(this.memory.normalizePath(filePath)) ?? false;
|
|
2860
|
+
}
|
|
2861
|
+
updateActiveConflictLocal(filePath, content, modifiedAt) {
|
|
2862
|
+
const prompt = this.activeConflictPrompt;
|
|
2863
|
+
const key = this.memory.normalizePath(filePath);
|
|
2864
|
+
const conflict = prompt?.conflicts.get(key);
|
|
2865
|
+
if (!prompt || !conflict) return { changed: false };
|
|
2866
|
+
const next = {
|
|
2867
|
+
...conflict,
|
|
2868
|
+
fileName: key,
|
|
2869
|
+
localContent: content,
|
|
2870
|
+
localModifiedAt: modifiedAt
|
|
2871
|
+
};
|
|
2872
|
+
const resolved = conflictIsResolved(next) ? [resolvedPromptConflict(next)] : [];
|
|
2873
|
+
if (resolved.length > 0) prompt.conflicts.delete(key);
|
|
2874
|
+
else prompt.conflicts.set(key, next);
|
|
2875
|
+
const conflicts = [...prompt.conflicts.values()];
|
|
2876
|
+
const cleared = conflicts.length === 0;
|
|
2877
|
+
if (cleared) this.activeConflictPrompt = null;
|
|
2878
|
+
return {
|
|
2879
|
+
changed: true,
|
|
2880
|
+
session: prompt.session,
|
|
2881
|
+
conflicts,
|
|
2882
|
+
cleared,
|
|
2883
|
+
resolved
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
updateActiveConflictRemote(filePath, content, modifiedAt) {
|
|
2887
|
+
const prompt = this.activeConflictPrompt;
|
|
2888
|
+
const key = this.memory.normalizePath(filePath);
|
|
2889
|
+
const conflict = prompt?.conflicts.get(key);
|
|
2890
|
+
if (!prompt || !conflict) return { changed: false };
|
|
2891
|
+
const next = {
|
|
2892
|
+
...conflict,
|
|
2893
|
+
fileName: key,
|
|
2894
|
+
remoteContent: content,
|
|
2895
|
+
remoteModifiedAt: modifiedAt
|
|
2896
|
+
};
|
|
2897
|
+
const resolved = conflictIsResolved(next) ? [resolvedPromptConflict(next)] : [];
|
|
2898
|
+
if (resolved.length > 0) prompt.conflicts.delete(key);
|
|
2899
|
+
else prompt.conflicts.set(key, next);
|
|
2900
|
+
const conflicts = [...prompt.conflicts.values()];
|
|
2901
|
+
const cleared = conflicts.length === 0;
|
|
2902
|
+
if (cleared) this.activeConflictPrompt = null;
|
|
2903
|
+
return {
|
|
2904
|
+
changed: true,
|
|
2905
|
+
session: prompt.session,
|
|
2906
|
+
conflicts,
|
|
2907
|
+
cleared,
|
|
2908
|
+
resolved
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
resetPrompts() {
|
|
2912
|
+
this.activeDeletePrompt = null;
|
|
2913
|
+
this.activeConflictPrompt = null;
|
|
2914
|
+
this.pendingSyncCompletionEvent = null;
|
|
2915
|
+
}
|
|
2916
|
+
cleanupUserActions() {
|
|
2917
|
+
this.resetPrompts();
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2499
2920
|
|
|
2500
2921
|
//#endregion
|
|
2501
2922
|
//#region src/controller.ts
|
|
2923
|
+
function createEventQueue() {
|
|
2924
|
+
let tail = Promise.resolve();
|
|
2925
|
+
return { enqueue(fn) {
|
|
2926
|
+
const run = tail.then(() => fn());
|
|
2927
|
+
tail = run.catch(() => {});
|
|
2928
|
+
return run;
|
|
2929
|
+
} };
|
|
2930
|
+
}
|
|
2502
2931
|
/** Log helper */
|
|
2503
2932
|
function log(level, message) {
|
|
2504
2933
|
return {
|
|
@@ -2507,16 +2936,34 @@ function log(level, message) {
|
|
|
2507
2936
|
message
|
|
2508
2937
|
};
|
|
2509
2938
|
}
|
|
2939
|
+
function updatePendingConflictRemote(pendingConflicts, fileName, content, modifiedAt) {
|
|
2940
|
+
const normalized = normalizeCodeFilePathWithExtension(fileName);
|
|
2941
|
+
let changed = false;
|
|
2942
|
+
const conflicts = pendingConflicts.map((conflict) => {
|
|
2943
|
+
if (normalizeCodeFilePathWithExtension(conflict.fileName) !== normalized) return conflict;
|
|
2944
|
+
changed = true;
|
|
2945
|
+
return {
|
|
2946
|
+
...conflict,
|
|
2947
|
+
fileName: normalized,
|
|
2948
|
+
remoteContent: content,
|
|
2949
|
+
remoteModifiedAt: modifiedAt
|
|
2950
|
+
};
|
|
2951
|
+
});
|
|
2952
|
+
return {
|
|
2953
|
+
changed,
|
|
2954
|
+
conflicts
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2510
2957
|
/**
|
|
2511
|
-
*
|
|
2958
|
+
* State transition
|
|
2512
2959
|
* Takes current state + event, returns new state + effects to execute
|
|
2513
2960
|
*/
|
|
2514
|
-
function transition(state, event) {
|
|
2961
|
+
function transition(state, event, read = {}) {
|
|
2515
2962
|
const effects = [];
|
|
2516
2963
|
switch (event.type) {
|
|
2517
2964
|
case "HANDSHAKE":
|
|
2518
|
-
if (state.
|
|
2519
|
-
effects.push(log("warn", `Received HANDSHAKE in
|
|
2965
|
+
if (state.phase !== "disconnected") {
|
|
2966
|
+
effects.push(log("warn", `Received HANDSHAKE in phase=${state.phase}, ignoring`));
|
|
2520
2967
|
return {
|
|
2521
2968
|
state,
|
|
2522
2969
|
effects
|
|
@@ -2528,15 +2975,26 @@ function transition(state, event) {
|
|
|
2528
2975
|
}, { type: "LOAD_PERSISTED_STATE" }, {
|
|
2529
2976
|
type: "SEND_MESSAGE",
|
|
2530
2977
|
payload: { type: "request-files" }
|
|
2978
|
+
}, {
|
|
2979
|
+
type: "EMIT_SYNC_STATUS",
|
|
2980
|
+
status: "initial_sync"
|
|
2531
2981
|
});
|
|
2532
2982
|
return {
|
|
2533
2983
|
state: {
|
|
2534
|
-
|
|
2535
|
-
mode: "handshaking",
|
|
2984
|
+
phase: "handshaking",
|
|
2536
2985
|
socket: event.socket
|
|
2537
2986
|
},
|
|
2538
2987
|
effects
|
|
2539
2988
|
};
|
|
2989
|
+
case "RESEND_SYNC_STATUS":
|
|
2990
|
+
effects.push(log("debug", `Re-emitting sync-status=${event.status} for duplicate handshake`), {
|
|
2991
|
+
type: "EMIT_SYNC_STATUS",
|
|
2992
|
+
status: event.status
|
|
2993
|
+
});
|
|
2994
|
+
return {
|
|
2995
|
+
state,
|
|
2996
|
+
effects
|
|
2997
|
+
};
|
|
2540
2998
|
case "FILE_SYNCED_CONFIRMATION":
|
|
2541
2999
|
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
2542
3000
|
type: "UPDATE_FILE_METADATA",
|
|
@@ -2549,27 +3007,15 @@ function transition(state, event) {
|
|
|
2549
3007
|
};
|
|
2550
3008
|
case "DISCONNECT":
|
|
2551
3009
|
effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
|
|
2552
|
-
if (state.mode === "conflict_resolution") {
|
|
2553
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2554
|
-
return {
|
|
2555
|
-
state: {
|
|
2556
|
-
...rest,
|
|
2557
|
-
mode: "disconnected",
|
|
2558
|
-
socket: null
|
|
2559
|
-
},
|
|
2560
|
-
effects
|
|
2561
|
-
};
|
|
2562
|
-
}
|
|
2563
3010
|
return {
|
|
2564
3011
|
state: {
|
|
2565
|
-
|
|
2566
|
-
mode: "disconnected",
|
|
3012
|
+
phase: "disconnected",
|
|
2567
3013
|
socket: null
|
|
2568
3014
|
},
|
|
2569
3015
|
effects
|
|
2570
3016
|
};
|
|
2571
3017
|
case "REQUEST_FILES":
|
|
2572
|
-
if (state.
|
|
3018
|
+
if (state.phase === "disconnected") {
|
|
2573
3019
|
effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
|
|
2574
3020
|
return {
|
|
2575
3021
|
state,
|
|
@@ -2582,8 +3028,8 @@ function transition(state, event) {
|
|
|
2582
3028
|
effects
|
|
2583
3029
|
};
|
|
2584
3030
|
case "REMOTE_FILE_LIST":
|
|
2585
|
-
if (state.
|
|
2586
|
-
effects.push(log("warn", `Received REMOTE_FILE_LIST in
|
|
3031
|
+
if (state.phase !== "handshaking") {
|
|
3032
|
+
effects.push(log("warn", `Received REMOTE_FILE_LIST in phase=${state.phase}, ignoring`));
|
|
2587
3033
|
return {
|
|
2588
3034
|
state,
|
|
2589
3035
|
effects
|
|
@@ -2596,39 +3042,36 @@ function transition(state, event) {
|
|
|
2596
3042
|
});
|
|
2597
3043
|
return {
|
|
2598
3044
|
state: {
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
pendingRemoteChanges: event.files
|
|
3045
|
+
phase: "snapshot_processing",
|
|
3046
|
+
socket: state.socket
|
|
2602
3047
|
},
|
|
2603
3048
|
effects
|
|
2604
3049
|
};
|
|
2605
3050
|
case "CONFLICTS_DETECTED": {
|
|
2606
|
-
if (state.
|
|
2607
|
-
effects.push(log("warn", `Received CONFLICTS_DETECTED in
|
|
3051
|
+
if (state.phase !== "snapshot_processing") {
|
|
3052
|
+
effects.push(log("warn", `Received CONFLICTS_DETECTED in phase=${state.phase}, ignoring`));
|
|
2608
3053
|
return {
|
|
2609
3054
|
state,
|
|
2610
3055
|
effects
|
|
2611
3056
|
};
|
|
2612
3057
|
}
|
|
2613
|
-
const { conflicts, safeWrites, localOnly } = event;
|
|
3058
|
+
const { conflicts, safeWrites, localOnly, remoteTotal } = event;
|
|
2614
3059
|
if (safeWrites.length > 0) {
|
|
2615
3060
|
effects.push(log("debug", `Applying ${safeWrites.length} safe writes`));
|
|
2616
|
-
if (wasRecentlyDisconnected()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
|
|
3061
|
+
if (read.wasRecentlyDisconnected?.()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
|
|
2617
3062
|
effects.push({
|
|
2618
3063
|
type: "WRITE_FILES",
|
|
2619
3064
|
files: safeWrites,
|
|
2620
|
-
silent: true
|
|
3065
|
+
silent: true,
|
|
3066
|
+
echoPolicy: "authoritative"
|
|
2621
3067
|
});
|
|
2622
3068
|
}
|
|
2623
3069
|
if (localOnly.length > 0) {
|
|
2624
3070
|
effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
|
|
2625
3071
|
for (const file of localOnly) effects.push({
|
|
2626
|
-
type: "
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
fileName: file.name,
|
|
2630
|
-
content: file.content
|
|
2631
|
-
}
|
|
3072
|
+
type: "SEND_LOCAL_CHANGE",
|
|
3073
|
+
fileName: file.name,
|
|
3074
|
+
content: file.content
|
|
2632
3075
|
});
|
|
2633
3076
|
}
|
|
2634
3077
|
if (conflicts.length > 0) {
|
|
@@ -2638,14 +3081,13 @@ function transition(state, event) {
|
|
|
2638
3081
|
});
|
|
2639
3082
|
return {
|
|
2640
3083
|
state: {
|
|
2641
|
-
|
|
2642
|
-
|
|
3084
|
+
phase: "conflict_resolution",
|
|
3085
|
+
socket: state.socket,
|
|
2643
3086
|
pendingConflicts: conflicts
|
|
2644
3087
|
},
|
|
2645
3088
|
effects
|
|
2646
3089
|
};
|
|
2647
3090
|
}
|
|
2648
|
-
const remoteTotal = state.pendingRemoteChanges.length;
|
|
2649
3091
|
const totalCount = remoteTotal + localOnly.length;
|
|
2650
3092
|
const updatedCount = safeWrites.length + localOnly.length;
|
|
2651
3093
|
const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
|
|
@@ -2657,24 +3099,51 @@ function transition(state, event) {
|
|
|
2657
3099
|
});
|
|
2658
3100
|
return {
|
|
2659
3101
|
state: {
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
pendingRemoteChanges: []
|
|
3102
|
+
phase: "watching",
|
|
3103
|
+
socket: state.socket
|
|
2663
3104
|
},
|
|
2664
3105
|
effects
|
|
2665
3106
|
};
|
|
2666
3107
|
}
|
|
2667
|
-
case "REMOTE_FILE_CHANGE":
|
|
2668
|
-
|
|
2669
|
-
|
|
3108
|
+
case "REMOTE_FILE_CHANGE":
|
|
3109
|
+
if (read.isActiveDeletePromptPath?.(event.file.name)) effects.push({
|
|
3110
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3111
|
+
fileName: event.file.name
|
|
3112
|
+
});
|
|
3113
|
+
if (read.isActiveConflictPath?.(event.file.name)) {
|
|
3114
|
+
effects.push(log("debug", `Updating active conflict from remote change: ${event.file.name}`), {
|
|
3115
|
+
type: "UPDATE_ACTIVE_CONFLICT_REMOTE",
|
|
3116
|
+
fileName: event.file.name,
|
|
3117
|
+
content: event.file.content,
|
|
3118
|
+
modifiedAt: event.file.modifiedAt
|
|
3119
|
+
});
|
|
3120
|
+
return {
|
|
3121
|
+
state,
|
|
3122
|
+
effects
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
if (state.phase === "conflict_resolution") {
|
|
3126
|
+
const next = updatePendingConflictRemote(state.pendingConflicts, event.file.name, event.file.content, event.file.modifiedAt);
|
|
3127
|
+
if (next.changed) {
|
|
3128
|
+
effects.push(log("debug", `Updating pending conflict from remote change: ${event.file.name}`));
|
|
3129
|
+
return {
|
|
3130
|
+
state: {
|
|
3131
|
+
...state,
|
|
3132
|
+
pendingConflicts: next.conflicts
|
|
3133
|
+
},
|
|
3134
|
+
effects
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
if (state.phase === "snapshot_processing" || state.phase === "handshaking") {
|
|
2670
3139
|
effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`));
|
|
2671
3140
|
return {
|
|
2672
3141
|
state,
|
|
2673
3142
|
effects
|
|
2674
3143
|
};
|
|
2675
3144
|
}
|
|
2676
|
-
if (
|
|
2677
|
-
effects.push(log("warn", `Rejected file change: ${event.file.name} (
|
|
3145
|
+
if (state.phase !== "watching" && state.phase !== "conflict_resolution") {
|
|
3146
|
+
effects.push(log("warn", `Rejected file change: ${event.file.name} (unknown-file)`));
|
|
2678
3147
|
return {
|
|
2679
3148
|
state,
|
|
2680
3149
|
effects
|
|
@@ -2683,15 +3152,43 @@ function transition(state, event) {
|
|
|
2683
3152
|
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
2684
3153
|
type: "WRITE_FILES",
|
|
2685
3154
|
files: [event.file],
|
|
2686
|
-
|
|
3155
|
+
echoPolicy: "skip-expected-echoes"
|
|
2687
3156
|
});
|
|
2688
3157
|
return {
|
|
2689
3158
|
state,
|
|
2690
3159
|
effects
|
|
2691
3160
|
};
|
|
2692
|
-
}
|
|
2693
3161
|
case "REMOTE_FILE_DELETE":
|
|
2694
|
-
if (
|
|
3162
|
+
if (read.isActiveDeletePromptPath?.(event.fileName)) effects.push({
|
|
3163
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3164
|
+
fileName: event.fileName
|
|
3165
|
+
});
|
|
3166
|
+
if (read.isActiveConflictPath?.(event.fileName)) {
|
|
3167
|
+
effects.push(log("debug", `Updating active conflict from remote delete: ${event.fileName}`), {
|
|
3168
|
+
type: "UPDATE_ACTIVE_CONFLICT_REMOTE",
|
|
3169
|
+
fileName: event.fileName,
|
|
3170
|
+
content: null,
|
|
3171
|
+
modifiedAt: Date.now()
|
|
3172
|
+
});
|
|
3173
|
+
return {
|
|
3174
|
+
state,
|
|
3175
|
+
effects
|
|
3176
|
+
};
|
|
3177
|
+
}
|
|
3178
|
+
if (state.phase === "conflict_resolution") {
|
|
3179
|
+
const next = updatePendingConflictRemote(state.pendingConflicts, event.fileName, null, Date.now());
|
|
3180
|
+
if (next.changed) {
|
|
3181
|
+
effects.push(log("debug", `Updating pending conflict from remote delete: ${event.fileName}`));
|
|
3182
|
+
return {
|
|
3183
|
+
state: {
|
|
3184
|
+
...state,
|
|
3185
|
+
pendingConflicts: next.conflicts
|
|
3186
|
+
},
|
|
3187
|
+
effects
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
if (state.phase === "disconnected") {
|
|
2695
3192
|
effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
|
|
2696
3193
|
return {
|
|
2697
3194
|
state,
|
|
@@ -2706,88 +3203,46 @@ function transition(state, event) {
|
|
|
2706
3203
|
state,
|
|
2707
3204
|
effects
|
|
2708
3205
|
};
|
|
2709
|
-
case "
|
|
2710
|
-
effects.push(
|
|
2711
|
-
type: "
|
|
2712
|
-
|
|
2713
|
-
|
|
3206
|
+
case "DELETE_CONFIRMED":
|
|
3207
|
+
effects.push({
|
|
3208
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3209
|
+
session: event.session,
|
|
3210
|
+
confirmedFileNames: event.fileNames,
|
|
3211
|
+
cancelledFiles: []
|
|
3212
|
+
});
|
|
2714
3213
|
return {
|
|
2715
3214
|
state,
|
|
2716
3215
|
effects
|
|
2717
3216
|
};
|
|
2718
|
-
case "
|
|
2719
|
-
effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
|
|
3217
|
+
case "DELETE_CANCELLED":
|
|
2720
3218
|
effects.push({
|
|
2721
|
-
type: "
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
modifiedAt: Date.now()
|
|
2726
|
-
}]
|
|
3219
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3220
|
+
session: event.session,
|
|
3221
|
+
confirmedFileNames: [],
|
|
3222
|
+
cancelledFiles: event.files
|
|
2727
3223
|
});
|
|
2728
3224
|
return {
|
|
2729
3225
|
state,
|
|
2730
3226
|
effects
|
|
2731
3227
|
};
|
|
2732
|
-
case "CONFLICTS_RESOLVED":
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
};
|
|
2739
|
-
}
|
|
2740
|
-
if (event.resolution === "remote") {
|
|
2741
|
-
for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
|
|
2742
|
-
type: "DELETE_LOCAL_FILES",
|
|
2743
|
-
names: [conflict.fileName]
|
|
2744
|
-
});
|
|
2745
|
-
else effects.push({
|
|
2746
|
-
type: "WRITE_FILES",
|
|
2747
|
-
files: [{
|
|
2748
|
-
name: conflict.fileName,
|
|
2749
|
-
content: conflict.remoteContent,
|
|
2750
|
-
modifiedAt: conflict.remoteModifiedAt
|
|
2751
|
-
}],
|
|
2752
|
-
silent: true
|
|
2753
|
-
});
|
|
2754
|
-
effects.push(log("success", "Keeping Framer changes"));
|
|
2755
|
-
} else {
|
|
2756
|
-
const localDeletes = [];
|
|
2757
|
-
for (const conflict of state.pendingConflicts) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
|
|
2758
|
-
else effects.push({
|
|
2759
|
-
type: "SEND_MESSAGE",
|
|
2760
|
-
payload: {
|
|
2761
|
-
type: "file-change",
|
|
2762
|
-
fileName: conflict.fileName,
|
|
2763
|
-
content: conflict.localContent
|
|
2764
|
-
}
|
|
2765
|
-
});
|
|
2766
|
-
if (localDeletes.length > 0) effects.push({
|
|
2767
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2768
|
-
fileNames: localDeletes
|
|
2769
|
-
});
|
|
2770
|
-
effects.push(log("success", "Keeping local changes"));
|
|
2771
|
-
}
|
|
2772
|
-
effects.push({ type: "PERSIST_STATE" }, {
|
|
2773
|
-
type: "SYNC_COMPLETE",
|
|
2774
|
-
totalCount: state.pendingConflicts.length,
|
|
2775
|
-
updatedCount: state.pendingConflicts.length,
|
|
2776
|
-
unchangedCount: 0
|
|
3228
|
+
case "CONFLICTS_RESOLVED":
|
|
3229
|
+
effects.push({
|
|
3230
|
+
type: "RESOLVE_CONFLICT_PROMPT",
|
|
3231
|
+
session: event.session,
|
|
3232
|
+
resolution: event.resolution,
|
|
3233
|
+
fileNames: event.fileNames
|
|
2777
3234
|
});
|
|
2778
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2779
3235
|
return {
|
|
2780
|
-
state: {
|
|
2781
|
-
|
|
2782
|
-
|
|
3236
|
+
state: state.phase === "disconnected" ? state : {
|
|
3237
|
+
phase: "watching",
|
|
3238
|
+
socket: state.socket
|
|
2783
3239
|
},
|
|
2784
3240
|
effects
|
|
2785
3241
|
};
|
|
2786
|
-
}
|
|
2787
3242
|
case "WATCHER_EVENT": {
|
|
2788
3243
|
const { kind, relativePath, content } = event.event;
|
|
2789
|
-
if (state.
|
|
2790
|
-
effects.push(log("debug", `Ignoring watcher event in
|
|
3244
|
+
if (state.phase !== "watching") {
|
|
3245
|
+
effects.push(log("debug", `Ignoring watcher event in phase=${state.phase}: ${kind} ${relativePath}`));
|
|
2791
3246
|
return {
|
|
2792
3247
|
state,
|
|
2793
3248
|
effects
|
|
@@ -2803,14 +3258,30 @@ function transition(state, event) {
|
|
|
2803
3258
|
effects
|
|
2804
3259
|
};
|
|
2805
3260
|
}
|
|
2806
|
-
effects.push({
|
|
3261
|
+
if (read.isActiveDeletePromptPath?.(relativePath)) effects.push({
|
|
3262
|
+
type: "INVALIDATE_DELETE_PROMPT_PATH",
|
|
3263
|
+
fileName: relativePath
|
|
3264
|
+
});
|
|
3265
|
+
if (read.isActiveConflictPath?.(relativePath)) effects.push(log("debug", `Updating active conflict from local change: ${relativePath}`), {
|
|
3266
|
+
type: "UPDATE_ACTIVE_CONFLICT_LOCAL",
|
|
3267
|
+
fileName: relativePath,
|
|
3268
|
+
content,
|
|
3269
|
+
modifiedAt: Date.now()
|
|
3270
|
+
});
|
|
3271
|
+
else effects.push({
|
|
2807
3272
|
type: "SEND_LOCAL_CHANGE",
|
|
2808
3273
|
fileName: relativePath,
|
|
2809
3274
|
content
|
|
2810
3275
|
});
|
|
2811
3276
|
break;
|
|
2812
3277
|
case "delete":
|
|
2813
|
-
effects.push(log("debug", `
|
|
3278
|
+
if (read.isActiveConflictPath?.(relativePath)) effects.push(log("debug", `Updating active conflict from local delete: ${relativePath}`), {
|
|
3279
|
+
type: "UPDATE_ACTIVE_CONFLICT_LOCAL",
|
|
3280
|
+
fileName: relativePath,
|
|
3281
|
+
content: null,
|
|
3282
|
+
modifiedAt: Date.now()
|
|
3283
|
+
});
|
|
3284
|
+
else effects.push(log("debug", `Local delete detected: ${relativePath}`), {
|
|
2814
3285
|
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2815
3286
|
fileNames: [relativePath]
|
|
2816
3287
|
});
|
|
@@ -2823,7 +3294,8 @@ function transition(state, event) {
|
|
|
2823
3294
|
effects
|
|
2824
3295
|
};
|
|
2825
3296
|
}
|
|
2826
|
-
effects.push(log("debug", `
|
|
3297
|
+
if (read.isActiveConflictPath?.(relativePath) || read.isActiveConflictPath?.(event.event.oldRelativePath)) effects.push(log("debug", `Ignoring rename touching active conflict: ${event.event.oldRelativePath} -> ${relativePath}`));
|
|
3298
|
+
else effects.push(log("debug", `Local rename detected: ${event.event.oldRelativePath} → ${relativePath}`), {
|
|
2827
3299
|
type: "SEND_FILE_RENAME",
|
|
2828
3300
|
oldFileName: event.event.oldRelativePath,
|
|
2829
3301
|
newFileName: relativePath,
|
|
@@ -2836,28 +3308,24 @@ function transition(state, event) {
|
|
|
2836
3308
|
effects
|
|
2837
3309
|
};
|
|
2838
3310
|
}
|
|
2839
|
-
case "
|
|
2840
|
-
if (state.
|
|
2841
|
-
effects.push(log("warn", `Received
|
|
3311
|
+
case "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS": {
|
|
3312
|
+
if (state.phase !== "conflict_resolution") {
|
|
3313
|
+
effects.push(log("warn", `Received RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS in phase=${state.phase}, ignoring`));
|
|
2842
3314
|
return {
|
|
2843
3315
|
state,
|
|
2844
3316
|
effects
|
|
2845
3317
|
};
|
|
2846
3318
|
}
|
|
2847
3319
|
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
|
|
3320
|
+
const localDeleteConflicts = [];
|
|
2848
3321
|
if (autoResolvedLocal.length > 0) {
|
|
2849
3322
|
effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
|
|
2850
|
-
const
|
|
2851
|
-
for (const conflict of autoResolvedLocal) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
|
|
3323
|
+
for (const conflict of autoResolvedLocal) if (conflict.localContent === null) localDeleteConflicts.push(conflict);
|
|
2852
3324
|
else effects.push({
|
|
2853
3325
|
type: "SEND_LOCAL_CHANGE",
|
|
2854
3326
|
fileName: conflict.fileName,
|
|
2855
3327
|
content: conflict.localContent
|
|
2856
3328
|
});
|
|
2857
|
-
if (localDeletes.length > 0) effects.push({
|
|
2858
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2859
|
-
fileNames: localDeletes
|
|
2860
|
-
});
|
|
2861
3329
|
}
|
|
2862
3330
|
if (autoResolvedRemote.length > 0) {
|
|
2863
3331
|
effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
|
|
@@ -2872,22 +3340,28 @@ function transition(state, event) {
|
|
|
2872
3340
|
content: conflict.remoteContent,
|
|
2873
3341
|
modifiedAt: conflict.remoteModifiedAt ?? Date.now()
|
|
2874
3342
|
}],
|
|
3343
|
+
echoPolicy: "authoritative",
|
|
2875
3344
|
silent: true
|
|
2876
3345
|
});
|
|
2877
3346
|
}
|
|
2878
|
-
|
|
2879
|
-
|
|
3347
|
+
const conflictsForPrompt = remainingConflicts.length > 0 ? [...remainingConflicts, ...localDeleteConflicts] : remainingConflicts;
|
|
3348
|
+
if (conflictsForPrompt.length > 0) {
|
|
3349
|
+
effects.push(log("warn", `${pluralize(conflictsForPrompt.length, "conflict")} require resolution`), {
|
|
2880
3350
|
type: "REQUEST_CONFLICT_DECISIONS",
|
|
2881
|
-
conflicts:
|
|
3351
|
+
conflicts: conflictsForPrompt
|
|
2882
3352
|
});
|
|
2883
3353
|
return {
|
|
2884
3354
|
state: {
|
|
2885
|
-
|
|
2886
|
-
|
|
3355
|
+
phase: "watching",
|
|
3356
|
+
socket: state.socket
|
|
2887
3357
|
},
|
|
2888
3358
|
effects
|
|
2889
3359
|
};
|
|
2890
3360
|
}
|
|
3361
|
+
if (localDeleteConflicts.length > 0) effects.push({
|
|
3362
|
+
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
3363
|
+
fileNames: localDeleteConflicts.map((conflict) => conflict.fileName)
|
|
3364
|
+
});
|
|
2891
3365
|
const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
|
|
2892
3366
|
effects.push({ type: "PERSIST_STATE" }, {
|
|
2893
3367
|
type: "SYNC_COMPLETE",
|
|
@@ -2895,12 +3369,10 @@ function transition(state, event) {
|
|
|
2895
3369
|
updatedCount: resolvedCount,
|
|
2896
3370
|
unchangedCount: 0
|
|
2897
3371
|
});
|
|
2898
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2899
3372
|
return {
|
|
2900
3373
|
state: {
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
pendingRemoteChanges: []
|
|
3374
|
+
phase: "watching",
|
|
3375
|
+
socket: state.socket
|
|
2904
3376
|
},
|
|
2905
3377
|
effects
|
|
2906
3378
|
};
|
|
@@ -2913,272 +3385,470 @@ function transition(state, event) {
|
|
|
2913
3385
|
};
|
|
2914
3386
|
}
|
|
2915
3387
|
}
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
3388
|
+
function emitLog(entry) {
|
|
3389
|
+
({
|
|
3390
|
+
info,
|
|
3391
|
+
debug,
|
|
3392
|
+
warn,
|
|
3393
|
+
success,
|
|
3394
|
+
status
|
|
3395
|
+
})[entry.level](entry.message);
|
|
3396
|
+
}
|
|
3397
|
+
function syncCompleteStatusMessage(config) {
|
|
3398
|
+
return config.once ? "Sync complete, exiting..." : "Watching for changes...";
|
|
3399
|
+
}
|
|
3400
|
+
function syncCompleteSuccessMessage(runtime, effect) {
|
|
3401
|
+
const relative = runtime.workspace.projectDir ? path.relative(process.cwd(), runtime.workspace.projectDir) : null;
|
|
3402
|
+
const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
|
|
3403
|
+
if (effect.totalCount === 0 && relativeDirectory) return runtime.workspace.projectDirCreated ? `Created ${relativeDirectory} folder` : `Syncing to ${relativeDirectory} folder`;
|
|
3404
|
+
if (relativeDirectory && runtime.workspace.projectDirCreated) return `Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`;
|
|
3405
|
+
if (relativeDirectory) return `Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`;
|
|
3406
|
+
return `Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`;
|
|
3407
|
+
}
|
|
3408
|
+
function sendFailureLabel(message) {
|
|
3409
|
+
return message.type === "file-change" ? message.fileName : message.type;
|
|
3410
|
+
}
|
|
3411
|
+
async function sendToPlugin(socket, message) {
|
|
3412
|
+
if (!socket) return false;
|
|
3413
|
+
try {
|
|
3414
|
+
return await sendMessage(socket, message);
|
|
3415
|
+
} catch {
|
|
3416
|
+
warn(`Failed to push ${sendFailureLabel(message)}`);
|
|
3417
|
+
return false;
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
async function writeFiles(files, ctx, options) {
|
|
3421
|
+
const { runtime } = ctx;
|
|
3422
|
+
if (!runtime.workspace.filesDir) return;
|
|
3423
|
+
const filesToWrite = options.echoPolicy === "skip-expected-echoes" ? filterEchoedFiles(files, runtime.memory) : files;
|
|
3424
|
+
if (options.echoPolicy === "skip-expected-echoes" && filesToWrite.length !== files.length) debug(`Skipped ${pluralize(files.length - filesToWrite.length, "echoed change")}`);
|
|
3425
|
+
const results = await writeRemoteFiles(filesToWrite, runtime.workspace.filesDir, runtime.memory);
|
|
3426
|
+
for (const result of results) {
|
|
3427
|
+
if (!result.ok) continue;
|
|
3428
|
+
if (!options.silent) fileDown(result.path);
|
|
3429
|
+
runtime.memory.recordSyncedContent(result.path, result.file.content, result.file.modifiedAt ?? Date.now());
|
|
3430
|
+
runtime.installer?.process(result.path, result.file.content);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
async function deleteFiles(fileNames, ctx) {
|
|
3434
|
+
const { runtime } = ctx;
|
|
3435
|
+
if (!runtime.workspace.filesDir) return;
|
|
3436
|
+
for (const fileName of fileNames) {
|
|
3437
|
+
const result = await deleteLocalFile(fileName, runtime.workspace.filesDir, runtime.memory);
|
|
3438
|
+
if (!result.ok) continue;
|
|
3439
|
+
fileDelete(result.fileName);
|
|
3440
|
+
runtime.memory.recordSyncedDelete(result.fileName);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
async function sendLocalChange(fileName, content, ctx) {
|
|
3444
|
+
const { runtime, syncState } = ctx;
|
|
3445
|
+
if (runtime.metadata.get(fileName)?.lastSyncedHash === hashFileContent(content)) {
|
|
3446
|
+
debug(`Skipping local change for ${fileName}: matches last synced content`);
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
if (runtime.memory.matchesContentEcho(fileName, content)) return;
|
|
3450
|
+
debug(`Local change detected: ${fileName}`);
|
|
3451
|
+
if (!await sendToPlugin(syncState.socket, {
|
|
3452
|
+
type: "file-change",
|
|
3453
|
+
fileName,
|
|
3454
|
+
content
|
|
3455
|
+
})) return;
|
|
3456
|
+
runtime.memory.armContentEcho(fileName, content);
|
|
3457
|
+
fileUp(fileName);
|
|
3458
|
+
runtime.installer?.process(fileName, content);
|
|
3459
|
+
}
|
|
3460
|
+
async function sendFileDelete(fileNames, ctx) {
|
|
3461
|
+
if (fileNames.length === 0) return;
|
|
3462
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3463
|
+
type: "file-delete",
|
|
3464
|
+
mode: "auto",
|
|
3465
|
+
fileNames
|
|
3466
|
+
})) return;
|
|
3467
|
+
for (const fileName of fileNames) ctx.runtime.memory.recordSyncedDelete(fileName);
|
|
3468
|
+
}
|
|
3469
|
+
async function sendFileRename(effect, ctx) {
|
|
3470
|
+
const { runtime, syncState } = ctx;
|
|
3471
|
+
const newFileName = normalizeCodeFilePathWithExtension(effect.newFileName);
|
|
3472
|
+
if (runtime.memory.matchesContentEcho(newFileName, effect.content) && runtime.memory.matchesExpectedDeleteEcho(effect.oldFileName)) {
|
|
3473
|
+
debug(`Skipping echoed rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3474
|
+
runtime.memory.clearContentEcho(newFileName);
|
|
3475
|
+
runtime.memory.clearExpectedDeleteEcho(effect.oldFileName);
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (!syncState.socket) {
|
|
3479
|
+
warn(`No socket available to send rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
if (await sendToPlugin(syncState.socket, {
|
|
3483
|
+
type: "file-rename",
|
|
3484
|
+
oldFileName: effect.oldFileName,
|
|
3485
|
+
newFileName,
|
|
3486
|
+
content: effect.content
|
|
3487
|
+
})) runtime.registerPendingRename(newFileName, {
|
|
3488
|
+
oldFileName: effect.oldFileName,
|
|
3489
|
+
content: effect.content
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
async function startDeletePrompt(fileNames, ctx) {
|
|
3493
|
+
const prompt = ctx.runtime.startDeletePrompt(fileNames);
|
|
3494
|
+
if (!prompt) return;
|
|
3495
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3496
|
+
type: "file-delete",
|
|
3497
|
+
mode: "confirm",
|
|
3498
|
+
fileNames: prompt.fileNames,
|
|
3499
|
+
session: prompt.session
|
|
3500
|
+
})) {
|
|
3501
|
+
ctx.runtime.clearDeletePromptFiles(prompt.session, prompt.fileNames);
|
|
3502
|
+
warn(`Failed to request delete confirmation for ${prompt.fileNames.join(", ")}`);
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
async function startConflictPrompt(conflicts, ctx) {
|
|
3506
|
+
const prompt = ctx.runtime.startOrUpdateConflictPrompt(conflicts);
|
|
3507
|
+
if (!prompt) return;
|
|
3508
|
+
if (!await sendToPlugin(ctx.syncState.socket, {
|
|
3509
|
+
type: "conflicts-detected",
|
|
3510
|
+
conflicts: prompt.conflicts,
|
|
3511
|
+
session: prompt.session
|
|
3512
|
+
})) {
|
|
3513
|
+
ctx.runtime.clearConflictPromptFiles(prompt.session, prompt.conflicts.map((conflict) => conflict.fileName));
|
|
3514
|
+
warn("Failed to send conflict prompt");
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
async function applyConflictChange(change, ctx) {
|
|
3518
|
+
if (!change.changed) return;
|
|
3519
|
+
for (const resolved of change.resolved) if (resolved.content === null) ctx.runtime.memory.recordSyncedDelete(resolved.fileName);
|
|
3520
|
+
else ctx.runtime.memory.recordSyncedContent(resolved.fileName, resolved.content, resolved.modifiedAt ?? Date.now());
|
|
3521
|
+
if (ctx.syncState.socket) await sendToPlugin(ctx.syncState.socket, change.cleared ? {
|
|
3522
|
+
type: "conflicts-cleared",
|
|
3523
|
+
session: change.session
|
|
3524
|
+
} : {
|
|
3525
|
+
type: "conflicts-detected",
|
|
3526
|
+
conflicts: change.conflicts,
|
|
3527
|
+
session: change.session
|
|
3528
|
+
});
|
|
3529
|
+
if (change.resolved.length > 0) await ctx.runtime.metadata.flush();
|
|
3530
|
+
if (change.cleared && ctx.runtime.lastEmittedSyncStatus !== "ready") {
|
|
3531
|
+
if (await flushPendingSyncComplete(ctx) !== "empty") return;
|
|
3532
|
+
await applySyncComplete({
|
|
3533
|
+
type: "SYNC_COMPLETE",
|
|
3534
|
+
totalCount: change.resolved.length,
|
|
3535
|
+
updatedCount: change.resolved.length,
|
|
3536
|
+
unchangedCount: 0
|
|
3537
|
+
}, ctx);
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
async function applySyncComplete(effect, ctx) {
|
|
3541
|
+
const { config, runtime, syncState, shutdown } = ctx;
|
|
3542
|
+
if (runtime.hasAnyActivePrompt()) {
|
|
3543
|
+
runtime.deferSyncComplete({
|
|
3544
|
+
totalCount: effect.totalCount,
|
|
3545
|
+
updatedCount: effect.updatedCount,
|
|
3546
|
+
unchangedCount: effect.unchangedCount
|
|
3547
|
+
});
|
|
3548
|
+
debug("Deferring sync completion until active prompts resolve");
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
const wasDisconnected = runtime.disconnectUi.wasRecentlyDisconnected();
|
|
3552
|
+
let shouldShutdown = !!config.once;
|
|
3553
|
+
let shouldTryGitInit = false;
|
|
3554
|
+
if (wasDisconnected) {
|
|
3555
|
+
const didShow = runtime.disconnectUi.didShowNotice();
|
|
3556
|
+
shouldShutdown = didShow && !!config.once;
|
|
3557
|
+
if (didShow) {
|
|
3558
|
+
success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3559
|
+
status(syncCompleteStatusMessage(config));
|
|
3560
|
+
}
|
|
3561
|
+
} else {
|
|
3562
|
+
const message = syncCompleteSuccessMessage(runtime, effect);
|
|
3563
|
+
if (message) success(message);
|
|
3564
|
+
status(syncCompleteStatusMessage(config));
|
|
3565
|
+
shouldTryGitInit = !!(runtime.workspace.projectDirCreated && runtime.workspace.projectDir);
|
|
3566
|
+
}
|
|
3567
|
+
await sendToPlugin(syncState.socket, {
|
|
3568
|
+
type: "sync-status",
|
|
3569
|
+
status: "ready"
|
|
3570
|
+
});
|
|
3571
|
+
runtime.noteEmittedSyncStatus("ready");
|
|
3572
|
+
if (wasDisconnected) runtime.disconnectUi.reset();
|
|
3573
|
+
if (shouldTryGitInit && runtime.workspace.projectDir) tryGitInit(runtime.workspace.projectDir);
|
|
3574
|
+
if (shouldShutdown) await shutdown();
|
|
3575
|
+
}
|
|
3576
|
+
async function flushPendingSyncComplete(ctx) {
|
|
3577
|
+
const result = ctx.runtime.claimPendingSyncComplete();
|
|
3578
|
+
if (result.status === "ready") await applySyncComplete({
|
|
3579
|
+
type: "SYNC_COMPLETE",
|
|
3580
|
+
...result.payload
|
|
3581
|
+
}, ctx);
|
|
3582
|
+
return result.status;
|
|
3583
|
+
}
|
|
3584
|
+
async function applyEffect(effect, ctx) {
|
|
3585
|
+
const { config, runtime, syncState } = ctx;
|
|
2922
3586
|
switch (effect.type) {
|
|
2923
|
-
case "INIT_WORKSPACE":
|
|
2924
|
-
if (
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
debug(`Files directory: ${config.filesDir}`);
|
|
2936
|
-
await fs.mkdir(config.filesDir, { recursive: true });
|
|
2937
|
-
}
|
|
3587
|
+
case "INIT_WORKSPACE": {
|
|
3588
|
+
if (runtime.workspace.projectDir) return [];
|
|
3589
|
+
const projectName = config.explicitName ?? effect.projectInfo.projectName;
|
|
3590
|
+
const directoryInfo = await findOrCreateProjectDirectory({
|
|
3591
|
+
projectHash: config.projectHash,
|
|
3592
|
+
projectName,
|
|
3593
|
+
explicitDirectory: config.explicitDirectory
|
|
3594
|
+
});
|
|
3595
|
+
runtime.configureWorkspace(directoryInfo.directory, directoryInfo.created);
|
|
3596
|
+
if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
|
|
3597
|
+
debug(`Files directory: ${runtime.workspace.filesDir}`);
|
|
3598
|
+
await fs.mkdir(runtime.workspace.filesDir, { recursive: true });
|
|
2938
3599
|
return [];
|
|
3600
|
+
}
|
|
2939
3601
|
case "LOAD_PERSISTED_STATE":
|
|
2940
|
-
if (
|
|
2941
|
-
await
|
|
2942
|
-
debug(`Loaded persisted metadata for ${pluralize(
|
|
3602
|
+
if (runtime.workspace.projectDir) {
|
|
3603
|
+
await runtime.metadata.initialize(runtime.workspace.projectDir);
|
|
3604
|
+
debug(`Loaded persisted metadata for ${pluralize(runtime.metadata.size(), "file")}`);
|
|
2943
3605
|
}
|
|
2944
3606
|
return [];
|
|
2945
|
-
case "LIST_LOCAL_FILES":
|
|
2946
|
-
if (
|
|
2947
|
-
const files = await listFiles(config.filesDir);
|
|
2948
|
-
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
3607
|
+
case "LIST_LOCAL_FILES":
|
|
3608
|
+
if (runtime.workspace.filesDir) await sendToPlugin(syncState.socket, {
|
|
2949
3609
|
type: "file-list",
|
|
2950
|
-
files
|
|
3610
|
+
files: await listFiles(runtime.workspace.filesDir)
|
|
2951
3611
|
});
|
|
2952
3612
|
return [];
|
|
2953
|
-
}
|
|
2954
3613
|
case "DETECT_CONFLICTS": {
|
|
2955
|
-
if (!
|
|
2956
|
-
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles,
|
|
2957
|
-
for (const file of unchanged)
|
|
3614
|
+
if (!runtime.workspace.filesDir) return [];
|
|
3615
|
+
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles, runtime.workspace.filesDir, { persistedState: runtime.metadata.getPersistedState() });
|
|
3616
|
+
for (const file of unchanged) runtime.memory.recordSyncedContent(file.name, file.content, file.modifiedAt ?? Date.now());
|
|
2958
3617
|
return [{
|
|
2959
3618
|
type: "CONFLICTS_DETECTED",
|
|
2960
3619
|
conflicts,
|
|
2961
3620
|
safeWrites: writes,
|
|
2962
|
-
localOnly
|
|
3621
|
+
localOnly,
|
|
3622
|
+
remoteTotal: effect.remoteFiles.length
|
|
2963
3623
|
}];
|
|
2964
3624
|
}
|
|
2965
3625
|
case "SEND_MESSAGE":
|
|
2966
|
-
if (
|
|
2967
|
-
|
|
2968
|
-
|
|
3626
|
+
if (effect.payload.type === "file-change") await sendLocalChange(effect.payload.fileName, effect.payload.content, ctx);
|
|
3627
|
+
else await sendToPlugin(syncState.socket, effect.payload);
|
|
3628
|
+
return [];
|
|
3629
|
+
case "EMIT_SYNC_STATUS":
|
|
3630
|
+
await sendToPlugin(syncState.socket, {
|
|
3631
|
+
type: "sync-status",
|
|
3632
|
+
status: effect.status
|
|
3633
|
+
});
|
|
3634
|
+
runtime.noteEmittedSyncStatus(effect.status);
|
|
2969
3635
|
return [];
|
|
2970
3636
|
case "WRITE_FILES":
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
await writeRemoteFiles(filesToWrite, config.filesDir, hashTracker, installer ?? void 0);
|
|
2976
|
-
for (const file of filesToWrite) {
|
|
2977
|
-
if (!effect.silent) fileDown(file.name);
|
|
2978
|
-
const remoteTimestamp = file.modifiedAt ?? Date.now();
|
|
2979
|
-
fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
|
|
2980
|
-
}
|
|
2981
|
-
}
|
|
3637
|
+
await writeFiles(effect.files, ctx, {
|
|
3638
|
+
silent: effect.silent,
|
|
3639
|
+
echoPolicy: effect.echoPolicy
|
|
3640
|
+
});
|
|
2982
3641
|
return [];
|
|
2983
3642
|
case "DELETE_LOCAL_FILES":
|
|
2984
|
-
|
|
2985
|
-
await deleteLocalFile(fileName, config.filesDir, hashTracker);
|
|
2986
|
-
fileDelete(fileName);
|
|
2987
|
-
fileMetadataCache.recordDelete(fileName);
|
|
2988
|
-
}
|
|
3643
|
+
await deleteFiles(effect.names, ctx);
|
|
2989
3644
|
return [];
|
|
2990
3645
|
case "REQUEST_CONFLICT_DECISIONS":
|
|
2991
|
-
await
|
|
3646
|
+
await startConflictPrompt(effect.conflicts, ctx);
|
|
2992
3647
|
return [];
|
|
2993
3648
|
case "REQUEST_CONFLICT_VERSIONS": {
|
|
2994
3649
|
if (!syncState.socket) {
|
|
2995
3650
|
warn("Cannot request conflict versions without active socket");
|
|
2996
3651
|
return [];
|
|
2997
3652
|
}
|
|
2998
|
-
const persistedState =
|
|
2999
|
-
const
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
});
|
|
3006
|
-
debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
|
|
3007
|
-
await sendMessage(syncState.socket, {
|
|
3653
|
+
const persistedState = runtime.metadata.getPersistedState();
|
|
3654
|
+
const conflicts = effect.conflicts.map((conflict) => ({
|
|
3655
|
+
fileName: conflict.fileName,
|
|
3656
|
+
lastSyncedAt: conflict.lastSyncedAt ?? persistedState.get(conflict.fileName)?.timestamp
|
|
3657
|
+
}));
|
|
3658
|
+
debug(`Requesting remote version data for ${pluralize(conflicts.length, "file")}`);
|
|
3659
|
+
await sendToPlugin(syncState.socket, {
|
|
3008
3660
|
type: "conflict-version-request",
|
|
3009
|
-
conflicts
|
|
3661
|
+
conflicts
|
|
3010
3662
|
});
|
|
3011
3663
|
return [];
|
|
3012
3664
|
}
|
|
3013
3665
|
case "UPDATE_FILE_METADATA": {
|
|
3014
|
-
if (!
|
|
3015
|
-
const currentContent = await readFileSafe(effect.fileName,
|
|
3016
|
-
const
|
|
3017
|
-
const syncedContent = currentContent ??
|
|
3018
|
-
if (syncedContent !== null)
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
hashTracker.forget(pendingRenameConfirmation.oldFileName);
|
|
3024
|
-
fileMetadataCache.recordDelete(pendingRenameConfirmation.oldFileName);
|
|
3025
|
-
if (currentContent !== null) hashTracker.remember(effect.fileName, currentContent);
|
|
3026
|
-
pendingRenameConfirmations.delete(normalizeCodeFilePathWithExtension(effect.fileName));
|
|
3666
|
+
if (!runtime.workspace.filesDir || !runtime.workspace.projectDir) return [];
|
|
3667
|
+
const currentContent = await readFileSafe(effect.fileName, runtime.workspace.filesDir);
|
|
3668
|
+
const pendingRename = runtime.getPendingRename(normalizeCodeFilePathWithExtension(effect.fileName));
|
|
3669
|
+
const syncedContent = currentContent ?? pendingRename?.content ?? null;
|
|
3670
|
+
if (syncedContent !== null) runtime.memory.recordSyncedContent(effect.fileName, syncedContent, effect.remoteModifiedAt);
|
|
3671
|
+
if (pendingRename) {
|
|
3672
|
+
runtime.memory.recordSyncedDelete(pendingRename.oldFileName);
|
|
3673
|
+
if (currentContent !== null) runtime.memory.armContentEcho(effect.fileName, currentContent);
|
|
3674
|
+
runtime.completePendingRename(effect.fileName);
|
|
3027
3675
|
}
|
|
3028
3676
|
return [];
|
|
3029
3677
|
}
|
|
3030
|
-
case "SEND_LOCAL_CHANGE":
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3678
|
+
case "SEND_LOCAL_CHANGE":
|
|
3679
|
+
await sendLocalChange(effect.fileName, effect.content, ctx);
|
|
3680
|
+
return [];
|
|
3681
|
+
case "SEND_FILE_RENAME":
|
|
3682
|
+
await sendFileRename(effect, ctx);
|
|
3683
|
+
return [];
|
|
3684
|
+
case "LOCAL_INITIATED_FILE_DELETE": {
|
|
3685
|
+
const filesToDelete = [];
|
|
3686
|
+
for (const fileName of effect.fileNames) if (runtime.memory.matchesExpectedDeleteEcho(fileName)) runtime.memory.clearExpectedDeleteEcho(fileName);
|
|
3687
|
+
else filesToDelete.push(fileName);
|
|
3688
|
+
if (filesToDelete.length === 0) return [];
|
|
3689
|
+
if (config.dangerouslyAutoDelete) await sendFileDelete(filesToDelete, ctx);
|
|
3690
|
+
else await startDeletePrompt(filesToDelete, ctx);
|
|
3691
|
+
return [];
|
|
3692
|
+
}
|
|
3693
|
+
case "RESOLVE_DELETE_PROMPT": {
|
|
3694
|
+
const activeFileNames = runtime.getDeletePromptFileNames(effect.session, [...effect.confirmedFileNames, ...effect.cancelledFiles.map((file) => file.fileName)]);
|
|
3695
|
+
if (!activeFileNames) {
|
|
3696
|
+
warn("Ignoring stale delete prompt response (session or paths mismatch)");
|
|
3034
3697
|
return [];
|
|
3035
3698
|
}
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
if (installer) installer.process(effect.fileName, effect.content);
|
|
3049
|
-
} catch (err) {
|
|
3050
|
-
warn(`Failed to push ${effect.fileName}`);
|
|
3051
|
-
}
|
|
3699
|
+
const active = new Set(activeFileNames);
|
|
3700
|
+
const confirmed = effect.confirmedFileNames.filter((fileName) => active.has(runtime.memory.normalizePath(fileName)));
|
|
3701
|
+
const cancelled = effect.cancelledFiles.filter((file) => active.has(runtime.memory.normalizePath(file.fileName)));
|
|
3702
|
+
if (cancelled.length > 0) await writeFiles(cancelled.map((file) => ({
|
|
3703
|
+
name: file.fileName,
|
|
3704
|
+
content: file.content,
|
|
3705
|
+
modifiedAt: Date.now()
|
|
3706
|
+
})), ctx, { echoPolicy: "authoritative" });
|
|
3707
|
+
await sendFileDelete(confirmed, ctx);
|
|
3708
|
+
runtime.clearDeletePromptFiles(effect.session, activeFileNames);
|
|
3709
|
+
await runtime.metadata.flush();
|
|
3710
|
+
await flushPendingSyncComplete(ctx);
|
|
3052
3711
|
return [];
|
|
3053
3712
|
}
|
|
3054
|
-
case "
|
|
3055
|
-
const
|
|
3056
|
-
if (
|
|
3057
|
-
|
|
3058
|
-
hashTracker.clearDelete(effect.oldFileName);
|
|
3059
|
-
debug(`Skipping echoed rename ${effect.oldFileName} -> ${effect.newFileName}`);
|
|
3713
|
+
case "RESOLVE_CONFLICT_PROMPT": {
|
|
3714
|
+
const conflicts = runtime.getConflictPromptConflicts(effect.session, effect.fileNames);
|
|
3715
|
+
if (!conflicts) {
|
|
3716
|
+
warn("Ignoring stale conflicts-resolved (session mismatch)");
|
|
3060
3717
|
return [];
|
|
3061
3718
|
}
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3719
|
+
if (effect.resolution === "remote") {
|
|
3720
|
+
const filesToWrite = [];
|
|
3721
|
+
const filesToDelete = [];
|
|
3722
|
+
for (const conflict of conflicts) {
|
|
3723
|
+
if (conflict.remoteContent === null) {
|
|
3724
|
+
filesToDelete.push(conflict.fileName);
|
|
3725
|
+
continue;
|
|
3726
|
+
}
|
|
3727
|
+
filesToWrite.push({
|
|
3728
|
+
name: conflict.fileName,
|
|
3729
|
+
content: conflict.remoteContent,
|
|
3730
|
+
modifiedAt: conflict.remoteModifiedAt
|
|
3731
|
+
});
|
|
3075
3732
|
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
});
|
|
3080
|
-
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3733
|
+
await Promise.all([writeFiles(filesToWrite, ctx, {
|
|
3734
|
+
silent: true,
|
|
3735
|
+
echoPolicy: "authoritative"
|
|
3736
|
+
}), deleteFiles(filesToDelete, ctx)]);
|
|
3737
|
+
} else for (const conflict of conflicts) if (conflict.localContent === null) await sendFileDelete([conflict.fileName], ctx);
|
|
3738
|
+
else await sendLocalChange(conflict.fileName, conflict.localContent, ctx);
|
|
3739
|
+
success(effect.resolution === "remote" ? "Keeping Framer changes" : "Keeping local changes");
|
|
3740
|
+
runtime.clearConflictPromptFiles(effect.session, effect.fileNames);
|
|
3741
|
+
await runtime.metadata.flush();
|
|
3742
|
+
await applySyncComplete({
|
|
3743
|
+
type: "SYNC_COMPLETE",
|
|
3744
|
+
totalCount: conflicts.length,
|
|
3745
|
+
updatedCount: conflicts.length,
|
|
3746
|
+
unchangedCount: 0
|
|
3747
|
+
}, ctx);
|
|
3083
3748
|
return [];
|
|
3084
3749
|
}
|
|
3085
|
-
case "
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
hashTracker.forget(fileName);
|
|
3099
|
-
fileMetadataCache.recordDelete(fileName);
|
|
3100
|
-
fileDelete(fileName);
|
|
3101
|
-
}
|
|
3102
|
-
if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
|
|
3103
|
-
type: "file-delete",
|
|
3104
|
-
fileNames: confirmedFiles
|
|
3750
|
+
case "UPDATE_ACTIVE_CONFLICT_LOCAL":
|
|
3751
|
+
await applyConflictChange(runtime.updateActiveConflictLocal(effect.fileName, effect.content, effect.modifiedAt), ctx);
|
|
3752
|
+
return [];
|
|
3753
|
+
case "UPDATE_ACTIVE_CONFLICT_REMOTE":
|
|
3754
|
+
await applyConflictChange(runtime.updateActiveConflictRemote(effect.fileName, effect.content, effect.modifiedAt), ctx);
|
|
3755
|
+
return [];
|
|
3756
|
+
case "INVALIDATE_DELETE_PROMPT_PATH": {
|
|
3757
|
+
const change = runtime.invalidateDeletePromptPath(effect.fileName);
|
|
3758
|
+
if (change.changed) {
|
|
3759
|
+
await sendToPlugin(syncState.socket, {
|
|
3760
|
+
type: "delete-prompt-cleared",
|
|
3761
|
+
session: change.session,
|
|
3762
|
+
fileNames: change.fileNames
|
|
3105
3763
|
});
|
|
3106
|
-
|
|
3107
|
-
console.warn(`Failed to handle deletion for ${filesToDelete.join(", ")}:`, err);
|
|
3764
|
+
await flushPendingSyncComplete(ctx);
|
|
3108
3765
|
}
|
|
3109
3766
|
return [];
|
|
3110
3767
|
}
|
|
3111
3768
|
case "PERSIST_STATE":
|
|
3112
|
-
await
|
|
3769
|
+
await runtime.metadata.flush();
|
|
3113
3770
|
return [];
|
|
3114
|
-
case "SYNC_COMPLETE":
|
|
3115
|
-
|
|
3116
|
-
if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
|
|
3117
|
-
if (wasDisconnected) {
|
|
3118
|
-
if (didShowDisconnect()) {
|
|
3119
|
-
success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3120
|
-
status("Watching for changes...");
|
|
3121
|
-
}
|
|
3122
|
-
resetDisconnectState();
|
|
3123
|
-
return [];
|
|
3124
|
-
}
|
|
3125
|
-
const relative = config.projectDir ? path.relative(process.cwd(), config.projectDir) : null;
|
|
3126
|
-
const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
|
|
3127
|
-
if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
|
|
3128
|
-
else success(`Syncing to ${relativeDirectory} folder`);
|
|
3129
|
-
else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
|
|
3130
|
-
else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
|
|
3131
|
-
else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3132
|
-
if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
|
|
3133
|
-
status("Watching for changes...");
|
|
3771
|
+
case "SYNC_COMPLETE":
|
|
3772
|
+
await applySyncComplete(effect, ctx);
|
|
3134
3773
|
return [];
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
success,
|
|
3141
|
-
debug
|
|
3142
|
-
}[effect.level];
|
|
3143
|
-
logFn(effect.message);
|
|
3774
|
+
case "LOG":
|
|
3775
|
+
emitLog({
|
|
3776
|
+
level: effect.level,
|
|
3777
|
+
message: effect.message
|
|
3778
|
+
});
|
|
3144
3779
|
return [];
|
|
3145
|
-
}
|
|
3146
3780
|
}
|
|
3147
3781
|
}
|
|
3148
3782
|
/**
|
|
3149
3783
|
* Starts the sync controller with the given configuration
|
|
3150
3784
|
*/
|
|
3151
3785
|
async function start(config) {
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
const pendingRenameConfirmations = /* @__PURE__ */ new Map();
|
|
3156
|
-
let installer = null;
|
|
3786
|
+
const runtime = new SyncRuntime();
|
|
3787
|
+
let isShuttingDown = false;
|
|
3788
|
+
let pendingDependencyVersions = null;
|
|
3157
3789
|
let syncState = {
|
|
3158
|
-
|
|
3159
|
-
socket: null
|
|
3160
|
-
pendingRemoteChanges: []
|
|
3790
|
+
phase: "disconnected",
|
|
3791
|
+
socket: null
|
|
3161
3792
|
};
|
|
3162
|
-
const
|
|
3163
|
-
|
|
3793
|
+
const eventQueue = createEventQueue();
|
|
3794
|
+
function nullDependencyVersions(packages) {
|
|
3795
|
+
return Object.fromEntries(packages.map((packageName) => [packageName, null]));
|
|
3796
|
+
}
|
|
3797
|
+
async function requestDependencyVersions(packages) {
|
|
3798
|
+
if (packages.length === 0) return {};
|
|
3799
|
+
const socket = syncState.socket;
|
|
3800
|
+
if (!socket) return nullDependencyVersions(packages);
|
|
3801
|
+
if (pendingDependencyVersions) {
|
|
3802
|
+
warn("Dependency version request already pending");
|
|
3803
|
+
return nullDependencyVersions(packages);
|
|
3804
|
+
}
|
|
3805
|
+
return await new Promise((resolve) => {
|
|
3806
|
+
const timeout = setTimeout(() => {
|
|
3807
|
+
if (pendingDependencyVersions?.resolve === resolve) {
|
|
3808
|
+
pendingDependencyVersions = null;
|
|
3809
|
+
warn("Timed out waiting for dependency versions from plugin");
|
|
3810
|
+
resolve(nullDependencyVersions(packages));
|
|
3811
|
+
}
|
|
3812
|
+
}, 1e4);
|
|
3813
|
+
pendingDependencyVersions = {
|
|
3814
|
+
resolve,
|
|
3815
|
+
timeout
|
|
3816
|
+
};
|
|
3817
|
+
sendMessage(socket, {
|
|
3818
|
+
type: "request-dependency-versions",
|
|
3819
|
+
packages
|
|
3820
|
+
}).then((sent) => {
|
|
3821
|
+
if (!sent && pendingDependencyVersions?.resolve === resolve) {
|
|
3822
|
+
clearTimeout(timeout);
|
|
3823
|
+
pendingDependencyVersions = null;
|
|
3824
|
+
resolve(nullDependencyVersions(packages));
|
|
3825
|
+
}
|
|
3826
|
+
});
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
function processEvent(event) {
|
|
3830
|
+
return eventQueue.enqueue(() => processEventInner(event));
|
|
3831
|
+
}
|
|
3832
|
+
async function processEventInner(event) {
|
|
3164
3833
|
const socketState = syncState.socket?.readyState;
|
|
3165
|
-
debug(`[STATE] Processing event: ${event.type} (
|
|
3166
|
-
const result = transition(syncState, event
|
|
3834
|
+
debug(`[STATE] Processing event: ${event.type} (phase: ${syncState.phase}, socket: ${socketState ?? "none"})`);
|
|
3835
|
+
const result = transition(syncState, event, {
|
|
3836
|
+
wasRecentlyDisconnected: () => runtime.disconnectUi.wasRecentlyDisconnected(),
|
|
3837
|
+
isActiveConflictPath: (fileName) => runtime.isActiveConflictPath(fileName),
|
|
3838
|
+
isActiveDeletePromptPath: (fileName) => runtime.isActiveDeletePromptPath(fileName)
|
|
3839
|
+
});
|
|
3167
3840
|
syncState = result.state;
|
|
3168
3841
|
if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
|
|
3169
3842
|
for (const effect of result.effects) {
|
|
3170
3843
|
const currentSocketState = syncState.socket?.readyState;
|
|
3171
3844
|
if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
|
|
3172
|
-
const followUpEvents = await
|
|
3845
|
+
const followUpEvents = await applyEffect(effect, {
|
|
3173
3846
|
config,
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
fileMetadataCache,
|
|
3177
|
-
pendingRenameConfirmations,
|
|
3178
|
-
userActions,
|
|
3847
|
+
runtime,
|
|
3848
|
+
shutdown,
|
|
3179
3849
|
syncState
|
|
3180
3850
|
});
|
|
3181
|
-
for (const followUpEvent of followUpEvents) await
|
|
3851
|
+
for (const followUpEvent of followUpEvents) await processEventInner(followUpEvent);
|
|
3182
3852
|
}
|
|
3183
3853
|
}
|
|
3184
3854
|
const certs = await getOrCreateCerts();
|
|
@@ -3187,10 +3857,11 @@ async function start(config) {
|
|
|
3187
3857
|
info("");
|
|
3188
3858
|
info("To fix this:");
|
|
3189
3859
|
info(" 1. Re-run this command — certificate generation is often a one-time issue");
|
|
3190
|
-
info(` 2. Manually delete "${
|
|
3860
|
+
info(` 2. Manually delete "${CERT_DIR}" and try again`);
|
|
3191
3861
|
info("");
|
|
3192
3862
|
throw new Error("TLS certificate generation failed");
|
|
3193
3863
|
}
|
|
3864
|
+
status("Waiting for Plugin connection...");
|
|
3194
3865
|
const connection = await initConnection(config.port, certs);
|
|
3195
3866
|
connection.on("handshake", (client, message) => {
|
|
3196
3867
|
debug(`Received handshake: ${message.projectName} (${message.projectId})`);
|
|
@@ -3202,17 +3873,23 @@ async function start(config) {
|
|
|
3202
3873
|
return;
|
|
3203
3874
|
}
|
|
3204
3875
|
(async () => {
|
|
3205
|
-
|
|
3206
|
-
if (syncState.
|
|
3876
|
+
runtime.disconnectUi.cancelNotice();
|
|
3877
|
+
if (syncState.phase !== "disconnected") {
|
|
3207
3878
|
if (syncState.socket === client) {
|
|
3208
|
-
|
|
3879
|
+
await processEvent({
|
|
3880
|
+
type: "RESEND_SYNC_STATUS",
|
|
3881
|
+
status: runtime.lastEmittedSyncStatus ?? "initial_sync"
|
|
3882
|
+
});
|
|
3209
3883
|
return;
|
|
3210
3884
|
}
|
|
3211
|
-
debug(`New handshake received
|
|
3212
|
-
|
|
3885
|
+
debug(`New handshake received (phase=${syncState.phase}), resetting sync state`);
|
|
3886
|
+
runtime.clearPendingRenames();
|
|
3887
|
+
runtime.clearEmittedSyncStatus();
|
|
3888
|
+
runtime.cleanupUserActions();
|
|
3213
3889
|
await processEvent({ type: "DISCONNECT" });
|
|
3214
3890
|
}
|
|
3215
|
-
|
|
3891
|
+
runtime.mintConnectionId();
|
|
3892
|
+
if (!runtime.disconnectUi.wasRecentlyDisconnected() && !runtime.disconnectUi.didShowNotice()) success(`Connected to ${message.projectName}`);
|
|
3216
3893
|
await processEvent({
|
|
3217
3894
|
type: "HANDSHAKE",
|
|
3218
3895
|
socket: client,
|
|
@@ -3221,18 +3898,20 @@ async function start(config) {
|
|
|
3221
3898
|
projectName: message.projectName
|
|
3222
3899
|
}
|
|
3223
3900
|
});
|
|
3224
|
-
if (
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3901
|
+
if (runtime.workspace.projectDir && !runtime.installer) {
|
|
3902
|
+
const npmStrategy = await resolveNpmStrategy(config, runtime.workspace.projectDir);
|
|
3903
|
+
runtime.installer = new Installer({
|
|
3904
|
+
projectDir: runtime.workspace.projectDir,
|
|
3905
|
+
npmStrategy,
|
|
3906
|
+
requestDependencyVersions
|
|
3228
3907
|
});
|
|
3229
|
-
await installer.initialize();
|
|
3908
|
+
await runtime.installer.initialize();
|
|
3230
3909
|
startWatcher();
|
|
3231
3910
|
}
|
|
3232
3911
|
})();
|
|
3233
3912
|
});
|
|
3234
3913
|
async function handleMessage(message) {
|
|
3235
|
-
if (!
|
|
3914
|
+
if (!runtime.workspace.projectDir || !runtime.installer) {
|
|
3236
3915
|
warn("Received message before handshake completed - ignoring");
|
|
3237
3916
|
return;
|
|
3238
3917
|
}
|
|
@@ -3255,8 +3934,7 @@ async function start(config) {
|
|
|
3255
3934
|
name: message.fileName,
|
|
3256
3935
|
content: message.content,
|
|
3257
3936
|
modifiedAt: Date.now()
|
|
3258
|
-
}
|
|
3259
|
-
fileMeta: fileMetadataCache.get(message.fileName)
|
|
3937
|
+
}
|
|
3260
3938
|
};
|
|
3261
3939
|
break;
|
|
3262
3940
|
case "file-delete":
|
|
@@ -3265,25 +3943,20 @@ async function start(config) {
|
|
|
3265
3943
|
fileName
|
|
3266
3944
|
});
|
|
3267
3945
|
return;
|
|
3268
|
-
case "delete-confirmed":
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
return;
|
|
3276
|
-
}
|
|
3946
|
+
case "delete-confirmed":
|
|
3947
|
+
event = {
|
|
3948
|
+
type: "DELETE_CONFIRMED",
|
|
3949
|
+
session: message.session,
|
|
3950
|
+
fileNames: message.fileNames
|
|
3951
|
+
};
|
|
3952
|
+
break;
|
|
3277
3953
|
case "delete-cancelled":
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
});
|
|
3285
|
-
}
|
|
3286
|
-
return;
|
|
3954
|
+
event = {
|
|
3955
|
+
type: "DELETE_CANCELLED",
|
|
3956
|
+
session: message.session,
|
|
3957
|
+
files: message.files
|
|
3958
|
+
};
|
|
3959
|
+
break;
|
|
3287
3960
|
case "file-synced":
|
|
3288
3961
|
event = {
|
|
3289
3962
|
type: "FILE_SYNCED_CONFIRMATION",
|
|
@@ -3292,21 +3965,34 @@ async function start(config) {
|
|
|
3292
3965
|
};
|
|
3293
3966
|
break;
|
|
3294
3967
|
case "error":
|
|
3295
|
-
if (message.fileName)
|
|
3968
|
+
if (message.fileName) runtime.completePendingRename(normalizeCodeFilePathWithExtension(message.fileName));
|
|
3296
3969
|
warn(message.message);
|
|
3297
3970
|
return;
|
|
3298
3971
|
case "conflicts-resolved":
|
|
3299
3972
|
event = {
|
|
3300
3973
|
type: "CONFLICTS_RESOLVED",
|
|
3301
|
-
|
|
3974
|
+
session: message.session,
|
|
3975
|
+
resolution: message.resolution,
|
|
3976
|
+
fileNames: message.fileNames
|
|
3302
3977
|
};
|
|
3303
3978
|
break;
|
|
3304
3979
|
case "conflict-version-response":
|
|
3305
3980
|
event = {
|
|
3306
|
-
type: "
|
|
3981
|
+
type: "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS",
|
|
3307
3982
|
versions: message.versions
|
|
3308
3983
|
};
|
|
3309
3984
|
break;
|
|
3985
|
+
case "dependency-versions": {
|
|
3986
|
+
if (!pendingDependencyVersions) {
|
|
3987
|
+
warn("Received dependency versions with no pending request");
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
clearTimeout(pendingDependencyVersions.timeout);
|
|
3991
|
+
const pending = pendingDependencyVersions;
|
|
3992
|
+
pendingDependencyVersions = null;
|
|
3993
|
+
pending.resolve(message.versions);
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3310
3996
|
default:
|
|
3311
3997
|
warn(`Unhandled message type: ${message.type}`);
|
|
3312
3998
|
return;
|
|
@@ -3323,26 +4009,42 @@ async function start(config) {
|
|
|
3323
4009
|
})();
|
|
3324
4010
|
});
|
|
3325
4011
|
connection.on("disconnect", (client) => {
|
|
4012
|
+
if (isShuttingDown) {
|
|
4013
|
+
debug("[STATE] Ignoring disconnect during shutdown");
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
3326
4016
|
if (syncState.socket !== client) {
|
|
3327
4017
|
debug("[STATE] Ignoring disconnect from stale socket");
|
|
3328
4018
|
return;
|
|
3329
4019
|
}
|
|
3330
|
-
|
|
4020
|
+
runtime.disconnectUi.scheduleNotice(() => {
|
|
3331
4021
|
status("Disconnected, waiting to reconnect...");
|
|
3332
4022
|
});
|
|
3333
4023
|
(async () => {
|
|
3334
|
-
|
|
4024
|
+
runtime.clearPendingRenames();
|
|
3335
4025
|
await processEvent({ type: "DISCONNECT" });
|
|
3336
|
-
|
|
4026
|
+
runtime.clearEmittedSyncStatus();
|
|
4027
|
+
runtime.cleanupUserActions();
|
|
3337
4028
|
})();
|
|
3338
4029
|
});
|
|
3339
4030
|
connection.on("error", (err) => {
|
|
3340
4031
|
error("Error on WebSocket connection:", err);
|
|
3341
4032
|
});
|
|
3342
4033
|
let watcher = null;
|
|
4034
|
+
const shutdown = async () => {
|
|
4035
|
+
if (isShuttingDown) return;
|
|
4036
|
+
debug("[STATE] Shutting down...");
|
|
4037
|
+
isShuttingDown = true;
|
|
4038
|
+
runtime.cleanupUserActions();
|
|
4039
|
+
if (watcher) {
|
|
4040
|
+
await watcher.close();
|
|
4041
|
+
watcher = null;
|
|
4042
|
+
}
|
|
4043
|
+
connection.close();
|
|
4044
|
+
};
|
|
3343
4045
|
const startWatcher = () => {
|
|
3344
|
-
if (!
|
|
3345
|
-
watcher = initWatcher(
|
|
4046
|
+
if (!runtime.workspace.filesDir || watcher) return;
|
|
4047
|
+
watcher = initWatcher(runtime.workspace.filesDir);
|
|
3346
4048
|
watcher.on("change", (event) => {
|
|
3347
4049
|
processEvent({
|
|
3348
4050
|
type: "WATCHER_EVENT",
|
|
@@ -3354,8 +4056,7 @@ async function start(config) {
|
|
|
3354
4056
|
console.log();
|
|
3355
4057
|
status("Shutting down...");
|
|
3356
4058
|
(async () => {
|
|
3357
|
-
|
|
3358
|
-
connection.close();
|
|
4059
|
+
await shutdown();
|
|
3359
4060
|
process.exit(0);
|
|
3360
4061
|
})();
|
|
3361
4062
|
});
|
|
@@ -3371,6 +4072,11 @@ async function start(config) {
|
|
|
3371
4072
|
*/
|
|
3372
4073
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
3373
4074
|
const program = new Command();
|
|
4075
|
+
function parseUnsupportedNpmMode(mode) {
|
|
4076
|
+
if (mode === void 0) return "acquire-types";
|
|
4077
|
+
if (mode === "acquire-types" || mode === "package-manager") return mode;
|
|
4078
|
+
throw new InvalidArgumentError("unsupported npm mode must be 'acquire-types' or 'package-manager'");
|
|
4079
|
+
}
|
|
3374
4080
|
program.exitOverride((err) => {
|
|
3375
4081
|
if (err.code === "commander.missingArgument") {
|
|
3376
4082
|
console.error("Missing Project ID. Copy command via Code Link Plugin.");
|
|
@@ -3378,7 +4084,7 @@ program.exitOverride((err) => {
|
|
|
3378
4084
|
}
|
|
3379
4085
|
throw err;
|
|
3380
4086
|
});
|
|
3381
|
-
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", "
|
|
4087
|
+
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) => {
|
|
3382
4088
|
if (!projectHash) {
|
|
3383
4089
|
const detected = await getProjectHashFromCwd();
|
|
3384
4090
|
if (detected) projectHash = detected;
|
|
@@ -3405,7 +4111,8 @@ program.name("framer-code-link").description("Sync Framer code components to you
|
|
|
3405
4111
|
projectDir: null,
|
|
3406
4112
|
filesDir: null,
|
|
3407
4113
|
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
3408
|
-
|
|
4114
|
+
npmStrategy: options.unsupportedNpm,
|
|
4115
|
+
once: options.once ?? false,
|
|
3409
4116
|
explicitDirectory: options.dir,
|
|
3410
4117
|
explicitName: options.name
|
|
3411
4118
|
};
|