framer-code-link 0.20.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 +1442 -772
- 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);
|
|
@@ -564,8 +531,8 @@ async function getOrCreateCerts() {
|
|
|
564
531
|
await generateCerts(mkcertPath);
|
|
565
532
|
status("Successfully generated certificates.");
|
|
566
533
|
return {
|
|
567
|
-
key: await fs.readFile(SERVER_KEY_PATH, "utf-8"),
|
|
568
|
-
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")
|
|
569
536
|
};
|
|
570
537
|
} catch (err) {
|
|
571
538
|
error(`Failed to set up TLS certificates: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -596,7 +563,7 @@ function getDownloadInfo() {
|
|
|
596
563
|
async function ensureMkcertBinary() {
|
|
597
564
|
const { url, expectedChecksum } = getDownloadInfo();
|
|
598
565
|
try {
|
|
599
|
-
await fs.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
566
|
+
await fs$1.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK);
|
|
600
567
|
if (await verifyFileChecksum(MKCERT_BIN_PATH, expectedChecksum)) {
|
|
601
568
|
debug("mkcert binary already available and verified");
|
|
602
569
|
return MKCERT_BIN_PATH;
|
|
@@ -611,11 +578,11 @@ async function ensureMkcertBinary() {
|
|
|
611
578
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
612
579
|
const actualChecksum = createHash("sha256").update(buffer).digest("hex");
|
|
613
580
|
if (actualChecksum !== expectedChecksum) throw new Error(`mkcert binary checksum mismatch — the download may have been tampered with.\n Expected: ${expectedChecksum}\n Actual: ${actualChecksum}`);
|
|
614
|
-
await fs.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
581
|
+
await fs$1.writeFile(MKCERT_BIN_PATH, buffer, { mode: 493 });
|
|
615
582
|
debug(`mkcert binary saved to ${MKCERT_BIN_PATH}`);
|
|
616
583
|
return MKCERT_BIN_PATH;
|
|
617
584
|
} catch (err) {
|
|
618
|
-
await fs.rm(MKCERT_BIN_PATH, { force: true });
|
|
585
|
+
await fs$1.rm(MKCERT_BIN_PATH, { force: true });
|
|
619
586
|
const message = err instanceof Error ? err.message : String(err);
|
|
620
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`);
|
|
621
588
|
}
|
|
@@ -661,13 +628,13 @@ async function syncRootCA(mkcertPath) {
|
|
|
661
628
|
} });
|
|
662
629
|
const defaultCAROOT = stdout.trim();
|
|
663
630
|
if (!defaultCAROOT || defaultCAROOT === CERT_DIR) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
664
|
-
const defaultRootCert = await loadFile(path.join(defaultCAROOT, "rootCA.pem"));
|
|
665
|
-
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"));
|
|
666
633
|
if (!defaultRootCert || !defaultRootKey) return existingRootCert && existingRootKey ? "unchanged" : "missing";
|
|
667
634
|
if (existingRootCert === defaultRootCert && existingRootKey === defaultRootKey) return "unchanged";
|
|
668
|
-
await Promise.all([fs.rm(ROOT_CA_CERT_PATH, { force: true }), fs.rm(ROOT_CA_KEY_PATH, { force: true })]);
|
|
669
|
-
await fs.writeFile(ROOT_CA_CERT_PATH, defaultRootCert, { mode: 420 });
|
|
670
|
-
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 });
|
|
671
638
|
return existingRootCert && existingRootKey ? "updated" : "copied";
|
|
672
639
|
}
|
|
673
640
|
async function invalidateServerCerts(rootCAState) {
|
|
@@ -677,22 +644,22 @@ async function invalidateServerCerts(rootCAState) {
|
|
|
677
644
|
missing: "No cached mkcert root CA was available for the existing server certificate"
|
|
678
645
|
};
|
|
679
646
|
if (!(await loadFile(SERVER_KEY_PATH) !== null || await loadFile(SERVER_CERT_PATH) !== null)) return;
|
|
680
|
-
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
681
|
-
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 });
|
|
682
649
|
debug(`${reasons[rootCAState]}; removed stale localhost certificate`);
|
|
683
650
|
}
|
|
684
651
|
async function invalidateIncompleteServerBundle() {
|
|
685
|
-
await fs.rm(SERVER_KEY_PATH, { force: true });
|
|
686
|
-
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 });
|
|
687
654
|
warn("Found an incomplete localhost certificate bundle; regenerating it");
|
|
688
655
|
}
|
|
689
656
|
async function verifyFileChecksum(filePath, expectedHash) {
|
|
690
|
-
const data = await fs.readFile(filePath);
|
|
657
|
+
const data = await fs$1.readFile(filePath);
|
|
691
658
|
return createHash("sha256").update(data).digest("hex") === expectedHash;
|
|
692
659
|
}
|
|
693
660
|
async function loadFile(filePath) {
|
|
694
661
|
try {
|
|
695
|
-
return await fs.readFile(filePath, "utf-8");
|
|
662
|
+
return await fs$1.readFile(filePath, "utf-8");
|
|
696
663
|
} catch {
|
|
697
664
|
return null;
|
|
698
665
|
}
|
|
@@ -806,6 +773,7 @@ function initConnection(port, certs) {
|
|
|
806
773
|
}
|
|
807
774
|
},
|
|
808
775
|
close() {
|
|
776
|
+
for (const client of wss.clients) client.close(1001);
|
|
809
777
|
wss.close();
|
|
810
778
|
httpsServer.close();
|
|
811
779
|
}
|
|
@@ -873,7 +841,7 @@ function persistedFileKey(fileName) {
|
|
|
873
841
|
* Hash file content to detect changes
|
|
874
842
|
*/
|
|
875
843
|
function hashFileContent(content) {
|
|
876
|
-
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
844
|
+
return createHash$1("sha256").update(content, "utf-8").digest("hex");
|
|
877
845
|
}
|
|
878
846
|
/**
|
|
879
847
|
* Load persisted state from disk
|
|
@@ -1135,42 +1103,67 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
|
|
|
1135
1103
|
remainingConflicts
|
|
1136
1104
|
};
|
|
1137
1105
|
}
|
|
1138
|
-
|
|
1139
|
-
* Writes remote files to disk and updates hash tracker to prevent echoes
|
|
1140
|
-
* CRITICAL: Update hashTracker BEFORE writing to disk
|
|
1141
|
-
*/
|
|
1142
|
-
async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
|
|
1106
|
+
async function writeRemoteFiles(files, filesDir, memory) {
|
|
1143
1107
|
debug(`Writing ${pluralize(files.length, "remote file")}`);
|
|
1144
|
-
|
|
1108
|
+
const results = [];
|
|
1109
|
+
for (const file of files) {
|
|
1145
1110
|
const normalized = resolveRemoteReference(filesDir, file.name);
|
|
1146
1111
|
const fullPath = normalized.absolutePath;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
+
}
|
|
1154
1137
|
}
|
|
1138
|
+
return results;
|
|
1155
1139
|
}
|
|
1156
|
-
|
|
1157
|
-
* Deletes a local file from disk
|
|
1158
|
-
*/
|
|
1159
|
-
async function deleteLocalFile(fileName, filesDir, hashTracker) {
|
|
1140
|
+
async function deleteLocalFile(fileName, filesDir, memory) {
|
|
1160
1141
|
const normalized = resolveRemoteReference(filesDir, fileName);
|
|
1142
|
+
const prepared = memory.armExpectedDeleteEcho(normalized.relativePath);
|
|
1161
1143
|
try {
|
|
1162
|
-
hashTracker.markDelete(normalized.relativePath);
|
|
1163
1144
|
await fs.unlink(normalized.absolutePath);
|
|
1164
|
-
hashTracker.forget(normalized.relativePath);
|
|
1165
1145
|
debug(`Deleted file: ${normalized.relativePath}`);
|
|
1146
|
+
return {
|
|
1147
|
+
fileName: normalized.relativePath,
|
|
1148
|
+
ok: true,
|
|
1149
|
+
alreadyMissing: false
|
|
1150
|
+
};
|
|
1166
1151
|
} catch (err) {
|
|
1167
1152
|
if (err.code === "ENOENT") {
|
|
1168
|
-
hashTracker.forget(normalized.relativePath);
|
|
1169
1153
|
debug(`File already deleted: ${normalized.relativePath}`);
|
|
1170
|
-
return
|
|
1154
|
+
return {
|
|
1155
|
+
fileName: normalized.relativePath,
|
|
1156
|
+
ok: true,
|
|
1157
|
+
alreadyMissing: true
|
|
1158
|
+
};
|
|
1171
1159
|
}
|
|
1172
|
-
|
|
1160
|
+
memory.rollbackExpectedDeleteEcho(prepared);
|
|
1173
1161
|
warn(`Failed to delete file ${fileName}:`, err);
|
|
1162
|
+
return {
|
|
1163
|
+
fileName: normalized.relativePath,
|
|
1164
|
+
ok: false,
|
|
1165
|
+
alreadyMissing: false
|
|
1166
|
+
};
|
|
1174
1167
|
}
|
|
1175
1168
|
}
|
|
1176
1169
|
/**
|
|
@@ -1188,9 +1181,9 @@ async function readFileSafe(fileName, filesDir) {
|
|
|
1188
1181
|
* Filter out files whose content matches the last remembered hash.
|
|
1189
1182
|
* Used to skip inbound echoes of our own local sends.
|
|
1190
1183
|
*/
|
|
1191
|
-
function filterEchoedFiles(files,
|
|
1184
|
+
function filterEchoedFiles(files, memory) {
|
|
1192
1185
|
return files.filter((file) => {
|
|
1193
|
-
return !
|
|
1186
|
+
return !memory.matchesContentEcho(file.name, file.content);
|
|
1194
1187
|
});
|
|
1195
1188
|
}
|
|
1196
1189
|
function resolveRemoteReference(filesDir, rawName) {
|
|
@@ -1285,7 +1278,7 @@ function tryGitInit(projectDir) {
|
|
|
1285
1278
|
return true;
|
|
1286
1279
|
} catch (e) {
|
|
1287
1280
|
if (didInit) try {
|
|
1288
|
-
|
|
1281
|
+
fs$2.rmSync(path.join(projectDir, ".git"), {
|
|
1289
1282
|
recursive: true,
|
|
1290
1283
|
force: true
|
|
1291
1284
|
});
|
|
@@ -1460,10 +1453,11 @@ const CORE_LIBRARIES = [
|
|
|
1460
1453
|
"react",
|
|
1461
1454
|
"react-dom"
|
|
1462
1455
|
];
|
|
1456
|
+
const PACKAGE_MANAGER_DEV_DEPENDENCIES = ["@types/react", "@types/react-dom"];
|
|
1463
1457
|
/** Packages with pinned type versions — used by ATA's `// types:` comment syntax */
|
|
1464
1458
|
const DEFAULT_PINNED_TYPE_VERSIONS = {
|
|
1465
1459
|
"framer-motion": "12.34.3",
|
|
1466
|
-
|
|
1460
|
+
react: "18.2.0",
|
|
1467
1461
|
"react-dom": "18.2.0",
|
|
1468
1462
|
"@types/react": "18.2.0",
|
|
1469
1463
|
"@types/react-dom": "18.2.0"
|
|
@@ -1489,15 +1483,19 @@ const SUPPORTED_PACKAGES = new Set([
|
|
|
1489
1483
|
*/
|
|
1490
1484
|
var Installer = class {
|
|
1491
1485
|
projectDir;
|
|
1492
|
-
|
|
1486
|
+
npmStrategy;
|
|
1487
|
+
requestDependencyVersions;
|
|
1493
1488
|
ata;
|
|
1494
1489
|
processedImports = /* @__PURE__ */ new Set();
|
|
1490
|
+
packageManagerPackages = /* @__PURE__ */ new Set();
|
|
1491
|
+
packageJsonRefreshPromise = Promise.resolve();
|
|
1495
1492
|
initializationPromise = null;
|
|
1496
1493
|
pinnedTypeVersions = { ...DEFAULT_PINNED_TYPE_VERSIONS };
|
|
1497
1494
|
pinnedTypeVersionsPromise = null;
|
|
1498
1495
|
constructor(config) {
|
|
1499
1496
|
this.projectDir = config.projectDir;
|
|
1500
|
-
this.
|
|
1497
|
+
this.npmStrategy = config.npmStrategy ?? "none";
|
|
1498
|
+
this.requestDependencyVersions = config.requestDependencyVersions ?? (async (packages) => Object.fromEntries(packages.map((packageName) => [packageName, null])));
|
|
1501
1499
|
const seenPackages = /* @__PURE__ */ new Set();
|
|
1502
1500
|
this.ata = setupTypeAcquisition({
|
|
1503
1501
|
projectName: "framer-code-link",
|
|
@@ -1575,10 +1573,15 @@ var Installer = class {
|
|
|
1575
1573
|
this.ensureSkills(),
|
|
1576
1574
|
this.ensureGitignore()
|
|
1577
1575
|
]);
|
|
1576
|
+
if (this.npmStrategy === "package-manager") {
|
|
1577
|
+
await this.resolvePinnedTypeVersions();
|
|
1578
|
+
await this.enqueuePackageJsonRefresh(await this.collectPackageManagerPackageNames());
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1578
1581
|
this.pinnedTypeVersionsPromise = this.resolvePinnedTypeVersions();
|
|
1579
1582
|
Promise.resolve().then(async () => {
|
|
1580
1583
|
const coreImports = await this.buildPinnedImports(CORE_LIBRARIES);
|
|
1581
|
-
const packageJsonDeps = this.
|
|
1584
|
+
const packageJsonDeps = this.npmStrategy === "acquire-types" ? Object.keys(this.pinnedTypeVersions).filter((name) => !SUPPORTED_PACKAGES.has(name)) : [];
|
|
1582
1585
|
const imports = [...coreImports, ...await this.buildPinnedImports(packageJsonDeps)].join("\n");
|
|
1583
1586
|
await this.ata(imports);
|
|
1584
1587
|
}).catch((err) => {
|
|
@@ -1588,16 +1591,20 @@ var Installer = class {
|
|
|
1588
1591
|
async processImports(fileName, content) {
|
|
1589
1592
|
const allImports = extractImports(content).filter((i) => i.type === "npm");
|
|
1590
1593
|
if (allImports.length === 0) return;
|
|
1591
|
-
|
|
1592
|
-
|
|
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)`);
|
|
1593
1600
|
if (imports.length === 0) return;
|
|
1594
1601
|
await this.pinnedTypeVersionsPromise;
|
|
1595
|
-
if (this.
|
|
1602
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1596
1603
|
const hash = imports.map((imp) => this.pinImport(imp.name)).sort().join(",");
|
|
1597
1604
|
if (this.processedImports.has(hash)) return;
|
|
1598
1605
|
this.processedImports.add(hash);
|
|
1599
1606
|
debug(`Processing imports for ${fileName} (${imports.length} packages)`);
|
|
1600
|
-
const filteredContent = this.
|
|
1607
|
+
const filteredContent = this.npmStrategy === "acquire-types" ? content : await this.buildFilteredImports(imports);
|
|
1601
1608
|
try {
|
|
1602
1609
|
await this.ata(filteredContent);
|
|
1603
1610
|
} catch (err) {
|
|
@@ -1605,13 +1612,82 @@ var Installer = class {
|
|
|
1605
1612
|
debug(`ATA error for ${fileName}:`, err);
|
|
1606
1613
|
}
|
|
1607
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
|
+
}
|
|
1608
1684
|
/**
|
|
1609
1685
|
* Check if a package is in the supported list.
|
|
1610
1686
|
* Also checks for subpath imports (e.g., "framer/build" -> "framer")
|
|
1611
1687
|
*/
|
|
1612
1688
|
isSupportedPackage(pkgName) {
|
|
1613
1689
|
if (SUPPORTED_PACKAGES.has(pkgName)) return true;
|
|
1614
|
-
const basePkg =
|
|
1690
|
+
const basePkg = getBasePackageName(pkgName);
|
|
1615
1691
|
return SUPPORTED_PACKAGES.has(basePkg);
|
|
1616
1692
|
}
|
|
1617
1693
|
/**
|
|
@@ -1637,7 +1713,7 @@ var Installer = class {
|
|
|
1637
1713
|
} catch (err) {
|
|
1638
1714
|
debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err);
|
|
1639
1715
|
}
|
|
1640
|
-
if (this.
|
|
1716
|
+
if (this.npmStrategy === "acquire-types") await this.resolvePackageJsonPins();
|
|
1641
1717
|
}
|
|
1642
1718
|
async resolvePackageJsonPins() {
|
|
1643
1719
|
try {
|
|
@@ -1662,7 +1738,7 @@ var Installer = class {
|
|
|
1662
1738
|
* Resolves the base package name for subpath imports (e.g., "framer-motion/dist" -> "framer-motion").
|
|
1663
1739
|
*/
|
|
1664
1740
|
pinImport(name) {
|
|
1665
|
-
const base =
|
|
1741
|
+
const base = getBasePackageName(name);
|
|
1666
1742
|
const version = this.pinnedTypeVersions[base];
|
|
1667
1743
|
if (version) return `import "${name}"; // types: ${version}`;
|
|
1668
1744
|
return `import "${name}";`;
|
|
@@ -1818,6 +1894,11 @@ declare module "*.json"
|
|
|
1818
1894
|
function getManifestDependencyVersion(manifest, packageName) {
|
|
1819
1895
|
return manifest.peerDependencies?.[packageName] ?? manifest.dependencies?.[packageName];
|
|
1820
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
|
+
}
|
|
1821
1902
|
function normalizePinnedVersion(version) {
|
|
1822
1903
|
if (!version) return void 0;
|
|
1823
1904
|
return /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(version)?.[0];
|
|
@@ -1887,140 +1968,216 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
|
|
|
1887
1968
|
}
|
|
1888
1969
|
|
|
1889
1970
|
//#endregion
|
|
1890
|
-
//#region src/
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
if (requireConfirmation) {
|
|
1919
|
-
const confirmationPromises = fileNames.map((fileName) => this.awaitAction(`delete:${fileName}`, "delete confirmation").then((confirmed) => confirmed ? fileName : null).catch((err) => {
|
|
1920
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1921
|
-
debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
|
|
1922
|
-
return null;
|
|
1923
|
-
}
|
|
1924
|
-
throw err;
|
|
1925
|
-
}));
|
|
1926
|
-
await sendMessage(socket, {
|
|
1927
|
-
type: "file-delete",
|
|
1928
|
-
fileNames,
|
|
1929
|
-
requireConfirmation: true
|
|
1930
|
-
});
|
|
1931
|
-
return (await Promise.all(confirmationPromises)).filter((name) => name !== null);
|
|
1932
|
-
}
|
|
1933
|
-
await sendMessage(socket, {
|
|
1934
|
-
type: "file-delete",
|
|
1935
|
-
fileNames,
|
|
1936
|
-
requireConfirmation: false
|
|
1937
|
-
});
|
|
1938
|
-
return fileNames;
|
|
1939
|
-
}
|
|
1940
|
-
/**
|
|
1941
|
-
* Sends conflicts to the plugin and awaits user resolutions
|
|
1942
|
-
*/
|
|
1943
|
-
async requestConflictDecisions(socket, conflicts) {
|
|
1944
|
-
if (!socket) throw new Error("Cannot request conflict decision: plugin not connected");
|
|
1945
|
-
if (conflicts.length === 0) return /* @__PURE__ */ new Map();
|
|
1946
|
-
const pending = conflicts.map((conflict) => ({
|
|
1947
|
-
fileName: conflict.fileName,
|
|
1948
|
-
promise: this.awaitAction(`conflict:${conflict.fileName}`, "conflict resolution")
|
|
1949
|
-
}));
|
|
1950
|
-
await sendMessage(socket, {
|
|
1951
|
-
type: "conflicts-detected",
|
|
1952
|
-
conflicts
|
|
1953
|
-
});
|
|
1954
|
-
try {
|
|
1955
|
-
const results = await Promise.all(pending.map(async ({ fileName, promise }) => [fileName, await promise]));
|
|
1956
|
-
return new Map(results);
|
|
1957
|
-
} catch (err) {
|
|
1958
|
-
if (err instanceof PluginDisconnectedError) {
|
|
1959
|
-
debug("Plugin disconnected while awaiting conflict decisions");
|
|
1960
|
-
return /* @__PURE__ */ new Map();
|
|
1961
|
-
}
|
|
1962
|
-
throw err;
|
|
1963
|
-
}
|
|
1971
|
+
//#region src/utils/project.ts
|
|
1972
|
+
function isPlainObject(value) {
|
|
1973
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Reads package.json, migrates legacy top-level Code Link fields into `codeLink`,
|
|
1977
|
+
* and persists when anything changed.
|
|
1978
|
+
*/
|
|
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;
|
|
1964
1999
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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 });
|
|
2021
|
+
return {
|
|
2022
|
+
directory: resolved,
|
|
2023
|
+
created: false
|
|
2024
|
+
};
|
|
1978
2025
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|
|
1986
2045
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2046
|
+
};
|
|
2047
|
+
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 4));
|
|
2048
|
+
return {
|
|
2049
|
+
directory: projectDirectory,
|
|
2050
|
+
created: true,
|
|
2051
|
+
nameCollision
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
1993
2054
|
/**
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
* During watching mode, we trust remote changes and apply them immediately.
|
|
1997
|
-
* During snapshot_processing, we queue them for later (to avoid race conditions).
|
|
1998
|
-
*
|
|
1999
|
-
* Note: This is for INCOMING changes from remote. Local changes (from watcher)
|
|
2000
|
-
* are handled separately and always sent during watching mode.
|
|
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.
|
|
2001
2057
|
*/
|
|
2002
|
-
function
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
action: "apply",
|
|
2010
|
-
reason: "new-file"
|
|
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
|
|
2011
2065
|
};
|
|
2066
|
+
} catch {
|
|
2012
2067
|
return {
|
|
2013
|
-
|
|
2014
|
-
|
|
2068
|
+
directory: candidate,
|
|
2069
|
+
nameCollision: false
|
|
2015
2070
|
};
|
|
2016
2071
|
}
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
};
|
|
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
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
//#endregion
|
|
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;
|
|
2107
|
+
}
|
|
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();
|
|
2021
2158
|
return {
|
|
2022
|
-
|
|
2023
|
-
|
|
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
|
+
}
|
|
2024
2181
|
};
|
|
2025
2182
|
}
|
|
2026
2183
|
|
|
@@ -2043,7 +2200,6 @@ function getRelativePath(projectDir, absolutePath) {
|
|
|
2043
2200
|
*
|
|
2044
2201
|
* Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json.
|
|
2045
2202
|
*/
|
|
2046
|
-
const RENAME_BUFFER_MS = 100;
|
|
2047
2203
|
function findUniqueHashMatch(pendingItems, contentHash) {
|
|
2048
2204
|
let matchingKey;
|
|
2049
2205
|
for (const [key, pending] of pendingItems) {
|
|
@@ -2079,6 +2235,7 @@ function matchPendingDeleteForAdd(contentHash, pendingDeletes) {
|
|
|
2079
2235
|
*/
|
|
2080
2236
|
function initWatcher(filesDir) {
|
|
2081
2237
|
const handlers = [];
|
|
2238
|
+
const scheduler = createScheduler();
|
|
2082
2239
|
const contentHashCache = /* @__PURE__ */ new Map();
|
|
2083
2240
|
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
2084
2241
|
const pendingAdds = /* @__PURE__ */ new Map();
|
|
@@ -2128,10 +2285,10 @@ function initWatcher(filesDir) {
|
|
|
2128
2285
|
effectiveAbsolutePath = newAbsolutePath;
|
|
2129
2286
|
recentSanitizations.add(rawRelativePath);
|
|
2130
2287
|
recentSanitizations.add(nextRelativePath);
|
|
2131
|
-
|
|
2288
|
+
scheduler.after("sanitizationEchoExpiry", TIMINGS.sanitizationEchoExpiry, () => {
|
|
2132
2289
|
recentSanitizations.delete(rawRelativePath);
|
|
2133
2290
|
recentSanitizations.delete(nextRelativePath);
|
|
2134
|
-
},
|
|
2291
|
+
}, `${rawRelativePath}\0${nextRelativePath}`);
|
|
2135
2292
|
} catch (err) {
|
|
2136
2293
|
warn(`Failed to rename ${rawRelativePath}`, err);
|
|
2137
2294
|
return {
|
|
@@ -2160,7 +2317,7 @@ function initWatcher(filesDir) {
|
|
|
2160
2317
|
contentHashCache.delete(relativePath);
|
|
2161
2318
|
const samePathPendingAdd = pendingAdds.get(relativePath);
|
|
2162
2319
|
if (samePathPendingAdd) {
|
|
2163
|
-
|
|
2320
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2164
2321
|
pendingAdds.delete(relativePath);
|
|
2165
2322
|
try {
|
|
2166
2323
|
const latestContent = await fs.readFile(effectiveAbsolutePath, "utf-8");
|
|
@@ -2182,7 +2339,7 @@ function initWatcher(filesDir) {
|
|
|
2182
2339
|
}
|
|
2183
2340
|
const matchedAdd = matchPendingAddForDelete(lastHash, pendingAdds);
|
|
2184
2341
|
if (matchedAdd) {
|
|
2185
|
-
|
|
2342
|
+
scheduler.cancel("renameBuffer", matchedAdd.key);
|
|
2186
2343
|
pendingAdds.delete(matchedAdd.key);
|
|
2187
2344
|
dispatchEvent({
|
|
2188
2345
|
kind: "rename",
|
|
@@ -2193,17 +2350,16 @@ function initWatcher(filesDir) {
|
|
|
2193
2350
|
return;
|
|
2194
2351
|
}
|
|
2195
2352
|
if (lastHash) {
|
|
2196
|
-
|
|
2353
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2197
2354
|
pendingDeletes.delete(relativePath);
|
|
2198
2355
|
dispatchEvent({
|
|
2199
2356
|
kind: "delete",
|
|
2200
2357
|
relativePath
|
|
2201
2358
|
});
|
|
2202
|
-
},
|
|
2359
|
+
}, relativePath);
|
|
2203
2360
|
pendingDeletes.set(relativePath, {
|
|
2204
2361
|
relativePath,
|
|
2205
|
-
contentHash: lastHash
|
|
2206
|
-
timer
|
|
2362
|
+
contentHash: lastHash
|
|
2207
2363
|
});
|
|
2208
2364
|
} else dispatchEvent({
|
|
2209
2365
|
kind: "delete",
|
|
@@ -2222,9 +2378,8 @@ function initWatcher(filesDir) {
|
|
|
2222
2378
|
const contentHash = hashFileContent(content);
|
|
2223
2379
|
contentHashCache.set(relativePath, contentHash);
|
|
2224
2380
|
if (kind === "add") {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
clearTimeout(samePathPendingDelete.timer);
|
|
2381
|
+
if (pendingDeletes.get(relativePath)) {
|
|
2382
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2228
2383
|
pendingDeletes.delete(relativePath);
|
|
2229
2384
|
dispatchEvent({
|
|
2230
2385
|
kind: "change",
|
|
@@ -2235,7 +2390,7 @@ function initWatcher(filesDir) {
|
|
|
2235
2390
|
}
|
|
2236
2391
|
const matchedDelete = matchPendingDeleteForAdd(contentHash, pendingDeletes);
|
|
2237
2392
|
if (matchedDelete) {
|
|
2238
|
-
|
|
2393
|
+
scheduler.cancel("renameBuffer", matchedDelete.key);
|
|
2239
2394
|
pendingDeletes.delete(matchedDelete.key);
|
|
2240
2395
|
dispatchEvent({
|
|
2241
2396
|
kind: "rename",
|
|
@@ -2246,28 +2401,26 @@ function initWatcher(filesDir) {
|
|
|
2246
2401
|
return;
|
|
2247
2402
|
}
|
|
2248
2403
|
const existingPendingAdd = pendingAdds.get(relativePath);
|
|
2249
|
-
if (existingPendingAdd)
|
|
2404
|
+
if (existingPendingAdd) scheduler.cancel("renameBuffer", relativePath);
|
|
2250
2405
|
const retainedPreviousContentHash = existingPendingAdd ? existingPendingAdd.previousContentHash : previousContentHash;
|
|
2251
|
-
|
|
2406
|
+
scheduler.after("renameBuffer", TIMINGS.renameBuffer, () => {
|
|
2252
2407
|
pendingAdds.delete(relativePath);
|
|
2253
2408
|
dispatchEvent({
|
|
2254
2409
|
kind: "add",
|
|
2255
2410
|
relativePath,
|
|
2256
2411
|
content
|
|
2257
2412
|
});
|
|
2258
|
-
},
|
|
2413
|
+
}, relativePath);
|
|
2259
2414
|
pendingAdds.set(relativePath, {
|
|
2260
2415
|
relativePath,
|
|
2261
2416
|
contentHash,
|
|
2262
2417
|
content,
|
|
2263
|
-
timer,
|
|
2264
2418
|
previousContentHash: retainedPreviousContentHash
|
|
2265
2419
|
});
|
|
2266
2420
|
return;
|
|
2267
2421
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
clearTimeout(pendingAdd.timer);
|
|
2422
|
+
if (pendingAdds.get(relativePath)) {
|
|
2423
|
+
scheduler.cancel("renameBuffer", relativePath);
|
|
2271
2424
|
pendingAdds.delete(relativePath);
|
|
2272
2425
|
dispatchEvent({
|
|
2273
2426
|
kind: "add",
|
|
@@ -2296,8 +2449,7 @@ function initWatcher(filesDir) {
|
|
|
2296
2449
|
handlers.push(handler);
|
|
2297
2450
|
},
|
|
2298
2451
|
async close() {
|
|
2299
|
-
|
|
2300
|
-
for (const pending of pendingAdds.values()) clearTimeout(pending.timer);
|
|
2452
|
+
scheduler.cancelAll();
|
|
2301
2453
|
pendingDeletes.clear();
|
|
2302
2454
|
pendingAdds.clear();
|
|
2303
2455
|
contentHashCache.clear();
|
|
@@ -2391,151 +2543,391 @@ var FileMetadataCache = class {
|
|
|
2391
2543
|
};
|
|
2392
2544
|
|
|
2393
2545
|
//#endregion
|
|
2394
|
-
//#region src/
|
|
2546
|
+
//#region src/sync-memory.ts
|
|
2395
2547
|
/**
|
|
2396
|
-
*
|
|
2548
|
+
* SyncMemory owns file-level sync truth.
|
|
2397
2549
|
*
|
|
2398
|
-
*
|
|
2399
|
-
*
|
|
2400
|
-
|
|
2401
|
-
/**
|
|
2402
|
-
* Creates a hash tracker instance for echo prevention
|
|
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.
|
|
2403
2553
|
*/
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
hashes.set(keyFor(filePath), hash);
|
|
2412
|
-
},
|
|
2413
|
-
shouldSkip(filePath, content) {
|
|
2414
|
-
const currentHash = hashFileContent(content);
|
|
2415
|
-
return hashes.get(keyFor(filePath)) === currentHash;
|
|
2416
|
-
},
|
|
2417
|
-
forget(filePath) {
|
|
2418
|
-
hashes.delete(keyFor(filePath));
|
|
2419
|
-
},
|
|
2420
|
-
clear() {
|
|
2421
|
-
hashes.clear();
|
|
2422
|
-
},
|
|
2423
|
-
markDelete(filePath) {
|
|
2424
|
-
const key = keyFor(filePath);
|
|
2425
|
-
const existingTimer = pendingDeletes.get(key);
|
|
2426
|
-
if (existingTimer) clearTimeout(existingTimer);
|
|
2427
|
-
const timeout = setTimeout(() => {
|
|
2428
|
-
pendingDeletes.delete(key);
|
|
2429
|
-
}, 5e3);
|
|
2430
|
-
pendingDeletes.set(key, timeout);
|
|
2431
|
-
},
|
|
2432
|
-
shouldSkipDelete(filePath) {
|
|
2433
|
-
return pendingDeletes.has(keyFor(filePath));
|
|
2434
|
-
},
|
|
2435
|
-
clearDelete(filePath) {
|
|
2436
|
-
const key = keyFor(filePath);
|
|
2437
|
-
const timeout = pendingDeletes.get(key);
|
|
2438
|
-
if (timeout) clearTimeout(timeout);
|
|
2439
|
-
pendingDeletes.delete(key);
|
|
2440
|
-
}
|
|
2441
|
-
};
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
//#endregion
|
|
2445
|
-
//#region src/utils/project.ts
|
|
2446
|
-
function toPackageName(name) {
|
|
2447
|
-
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2448
|
-
}
|
|
2449
|
-
function toDirectoryName(name) {
|
|
2450
|
-
return name.replace(/[^a-zA-Z0-9 -]/g, "-").trim().replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
2451
|
-
}
|
|
2452
|
-
async function getProjectHashFromCwd() {
|
|
2453
|
-
try {
|
|
2454
|
-
const packageJsonPath = path.join(process.cwd(), "package.json");
|
|
2455
|
-
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
2456
|
-
return JSON.parse(content).shortProjectHash ?? null;
|
|
2457
|
-
} catch {
|
|
2458
|
-
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);
|
|
2459
2561
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
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));
|
|
2466
2581
|
return {
|
|
2467
|
-
|
|
2468
|
-
|
|
2582
|
+
path,
|
|
2583
|
+
content
|
|
2469
2584
|
};
|
|
2470
2585
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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()
|
|
2476
2639
|
};
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
private: true,
|
|
2487
|
-
shortProjectHash: shortId,
|
|
2488
|
-
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
|
|
2489
2649
|
};
|
|
2490
|
-
|
|
2650
|
+
}
|
|
2651
|
+
function normalizeConflict(filePath, conflict) {
|
|
2491
2652
|
return {
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
nameCollision
|
|
2653
|
+
...conflict,
|
|
2654
|
+
fileName: filePath(conflict.fileName)
|
|
2495
2655
|
};
|
|
2496
2656
|
}
|
|
2497
2657
|
/**
|
|
2498
|
-
*
|
|
2499
|
-
*
|
|
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`.
|
|
2500
2662
|
*/
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
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;
|
|
2505
2800
|
return {
|
|
2506
|
-
|
|
2507
|
-
|
|
2801
|
+
status: "ready",
|
|
2802
|
+
payload: syncComplete
|
|
2508
2803
|
};
|
|
2509
|
-
}
|
|
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;
|
|
2510
2812
|
return {
|
|
2511
|
-
|
|
2512
|
-
|
|
2813
|
+
changed: true,
|
|
2814
|
+
session: prompt.session,
|
|
2815
|
+
fileNames: [normalized],
|
|
2816
|
+
cleared
|
|
2513
2817
|
};
|
|
2514
2818
|
}
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
const
|
|
2522
|
-
|
|
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;
|
|
2523
2836
|
}
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
const inputShort = shortProjectHash(projectHash);
|
|
2531
|
-
return pkg.shortProjectHash === inputShort;
|
|
2532
|
-
} catch {
|
|
2533
|
-
return false;
|
|
2837
|
+
getActiveConflictPrompt() {
|
|
2838
|
+
const prompt = this.activeConflictPrompt;
|
|
2839
|
+
return prompt ? {
|
|
2840
|
+
session: prompt.session,
|
|
2841
|
+
conflicts: [...prompt.conflicts.values()]
|
|
2842
|
+
} : null;
|
|
2534
2843
|
}
|
|
2535
|
-
|
|
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
|
+
};
|
|
2536
2920
|
|
|
2537
2921
|
//#endregion
|
|
2538
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
|
+
}
|
|
2539
2931
|
/** Log helper */
|
|
2540
2932
|
function log(level, message) {
|
|
2541
2933
|
return {
|
|
@@ -2544,16 +2936,34 @@ function log(level, message) {
|
|
|
2544
2936
|
message
|
|
2545
2937
|
};
|
|
2546
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
|
+
}
|
|
2547
2957
|
/**
|
|
2548
|
-
*
|
|
2958
|
+
* State transition
|
|
2549
2959
|
* Takes current state + event, returns new state + effects to execute
|
|
2550
2960
|
*/
|
|
2551
|
-
function transition(state, event) {
|
|
2961
|
+
function transition(state, event, read = {}) {
|
|
2552
2962
|
const effects = [];
|
|
2553
2963
|
switch (event.type) {
|
|
2554
2964
|
case "HANDSHAKE":
|
|
2555
|
-
if (state.
|
|
2556
|
-
effects.push(log("warn", `Received HANDSHAKE in
|
|
2965
|
+
if (state.phase !== "disconnected") {
|
|
2966
|
+
effects.push(log("warn", `Received HANDSHAKE in phase=${state.phase}, ignoring`));
|
|
2557
2967
|
return {
|
|
2558
2968
|
state,
|
|
2559
2969
|
effects
|
|
@@ -2565,15 +2975,26 @@ function transition(state, event) {
|
|
|
2565
2975
|
}, { type: "LOAD_PERSISTED_STATE" }, {
|
|
2566
2976
|
type: "SEND_MESSAGE",
|
|
2567
2977
|
payload: { type: "request-files" }
|
|
2978
|
+
}, {
|
|
2979
|
+
type: "EMIT_SYNC_STATUS",
|
|
2980
|
+
status: "initial_sync"
|
|
2568
2981
|
});
|
|
2569
2982
|
return {
|
|
2570
2983
|
state: {
|
|
2571
|
-
|
|
2572
|
-
mode: "handshaking",
|
|
2984
|
+
phase: "handshaking",
|
|
2573
2985
|
socket: event.socket
|
|
2574
2986
|
},
|
|
2575
2987
|
effects
|
|
2576
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
|
+
};
|
|
2577
2998
|
case "FILE_SYNCED_CONFIRMATION":
|
|
2578
2999
|
effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
|
|
2579
3000
|
type: "UPDATE_FILE_METADATA",
|
|
@@ -2586,27 +3007,15 @@ function transition(state, event) {
|
|
|
2586
3007
|
};
|
|
2587
3008
|
case "DISCONNECT":
|
|
2588
3009
|
effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
|
|
2589
|
-
if (state.mode === "conflict_resolution") {
|
|
2590
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2591
|
-
return {
|
|
2592
|
-
state: {
|
|
2593
|
-
...rest,
|
|
2594
|
-
mode: "disconnected",
|
|
2595
|
-
socket: null
|
|
2596
|
-
},
|
|
2597
|
-
effects
|
|
2598
|
-
};
|
|
2599
|
-
}
|
|
2600
3010
|
return {
|
|
2601
3011
|
state: {
|
|
2602
|
-
|
|
2603
|
-
mode: "disconnected",
|
|
3012
|
+
phase: "disconnected",
|
|
2604
3013
|
socket: null
|
|
2605
3014
|
},
|
|
2606
3015
|
effects
|
|
2607
3016
|
};
|
|
2608
3017
|
case "REQUEST_FILES":
|
|
2609
|
-
if (state.
|
|
3018
|
+
if (state.phase === "disconnected") {
|
|
2610
3019
|
effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
|
|
2611
3020
|
return {
|
|
2612
3021
|
state,
|
|
@@ -2619,8 +3028,8 @@ function transition(state, event) {
|
|
|
2619
3028
|
effects
|
|
2620
3029
|
};
|
|
2621
3030
|
case "REMOTE_FILE_LIST":
|
|
2622
|
-
if (state.
|
|
2623
|
-
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`));
|
|
2624
3033
|
return {
|
|
2625
3034
|
state,
|
|
2626
3035
|
effects
|
|
@@ -2633,39 +3042,36 @@ function transition(state, event) {
|
|
|
2633
3042
|
});
|
|
2634
3043
|
return {
|
|
2635
3044
|
state: {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
pendingRemoteChanges: event.files
|
|
3045
|
+
phase: "snapshot_processing",
|
|
3046
|
+
socket: state.socket
|
|
2639
3047
|
},
|
|
2640
3048
|
effects
|
|
2641
3049
|
};
|
|
2642
3050
|
case "CONFLICTS_DETECTED": {
|
|
2643
|
-
if (state.
|
|
2644
|
-
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`));
|
|
2645
3053
|
return {
|
|
2646
3054
|
state,
|
|
2647
3055
|
effects
|
|
2648
3056
|
};
|
|
2649
3057
|
}
|
|
2650
|
-
const { conflicts, safeWrites, localOnly } = event;
|
|
3058
|
+
const { conflicts, safeWrites, localOnly, remoteTotal } = event;
|
|
2651
3059
|
if (safeWrites.length > 0) {
|
|
2652
3060
|
effects.push(log("debug", `Applying ${safeWrites.length} safe writes`));
|
|
2653
|
-
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`));
|
|
2654
3062
|
effects.push({
|
|
2655
3063
|
type: "WRITE_FILES",
|
|
2656
3064
|
files: safeWrites,
|
|
2657
|
-
silent: true
|
|
3065
|
+
silent: true,
|
|
3066
|
+
echoPolicy: "authoritative"
|
|
2658
3067
|
});
|
|
2659
3068
|
}
|
|
2660
3069
|
if (localOnly.length > 0) {
|
|
2661
3070
|
effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
|
|
2662
3071
|
for (const file of localOnly) effects.push({
|
|
2663
|
-
type: "
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
fileName: file.name,
|
|
2667
|
-
content: file.content
|
|
2668
|
-
}
|
|
3072
|
+
type: "SEND_LOCAL_CHANGE",
|
|
3073
|
+
fileName: file.name,
|
|
3074
|
+
content: file.content
|
|
2669
3075
|
});
|
|
2670
3076
|
}
|
|
2671
3077
|
if (conflicts.length > 0) {
|
|
@@ -2675,14 +3081,13 @@ function transition(state, event) {
|
|
|
2675
3081
|
});
|
|
2676
3082
|
return {
|
|
2677
3083
|
state: {
|
|
2678
|
-
|
|
2679
|
-
|
|
3084
|
+
phase: "conflict_resolution",
|
|
3085
|
+
socket: state.socket,
|
|
2680
3086
|
pendingConflicts: conflicts
|
|
2681
3087
|
},
|
|
2682
3088
|
effects
|
|
2683
3089
|
};
|
|
2684
3090
|
}
|
|
2685
|
-
const remoteTotal = state.pendingRemoteChanges.length;
|
|
2686
3091
|
const totalCount = remoteTotal + localOnly.length;
|
|
2687
3092
|
const updatedCount = safeWrites.length + localOnly.length;
|
|
2688
3093
|
const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
|
|
@@ -2694,24 +3099,51 @@ function transition(state, event) {
|
|
|
2694
3099
|
});
|
|
2695
3100
|
return {
|
|
2696
3101
|
state: {
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
pendingRemoteChanges: []
|
|
3102
|
+
phase: "watching",
|
|
3103
|
+
socket: state.socket
|
|
2700
3104
|
},
|
|
2701
3105
|
effects
|
|
2702
3106
|
};
|
|
2703
3107
|
}
|
|
2704
|
-
case "REMOTE_FILE_CHANGE":
|
|
2705
|
-
|
|
2706
|
-
|
|
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") {
|
|
2707
3139
|
effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`));
|
|
2708
3140
|
return {
|
|
2709
3141
|
state,
|
|
2710
3142
|
effects
|
|
2711
3143
|
};
|
|
2712
3144
|
}
|
|
2713
|
-
if (
|
|
2714
|
-
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)`));
|
|
2715
3147
|
return {
|
|
2716
3148
|
state,
|
|
2717
3149
|
effects
|
|
@@ -2720,15 +3152,43 @@ function transition(state, event) {
|
|
|
2720
3152
|
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
2721
3153
|
type: "WRITE_FILES",
|
|
2722
3154
|
files: [event.file],
|
|
2723
|
-
|
|
3155
|
+
echoPolicy: "skip-expected-echoes"
|
|
2724
3156
|
});
|
|
2725
3157
|
return {
|
|
2726
3158
|
state,
|
|
2727
3159
|
effects
|
|
2728
3160
|
};
|
|
2729
|
-
}
|
|
2730
3161
|
case "REMOTE_FILE_DELETE":
|
|
2731
|
-
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") {
|
|
2732
3192
|
effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
|
|
2733
3193
|
return {
|
|
2734
3194
|
state,
|
|
@@ -2743,88 +3203,46 @@ function transition(state, event) {
|
|
|
2743
3203
|
state,
|
|
2744
3204
|
effects
|
|
2745
3205
|
};
|
|
2746
|
-
case "
|
|
2747
|
-
effects.push(
|
|
2748
|
-
type: "
|
|
2749
|
-
|
|
2750
|
-
|
|
3206
|
+
case "DELETE_CONFIRMED":
|
|
3207
|
+
effects.push({
|
|
3208
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3209
|
+
session: event.session,
|
|
3210
|
+
confirmedFileNames: event.fileNames,
|
|
3211
|
+
cancelledFiles: []
|
|
3212
|
+
});
|
|
2751
3213
|
return {
|
|
2752
3214
|
state,
|
|
2753
3215
|
effects
|
|
2754
3216
|
};
|
|
2755
|
-
case "
|
|
2756
|
-
effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
|
|
3217
|
+
case "DELETE_CANCELLED":
|
|
2757
3218
|
effects.push({
|
|
2758
|
-
type: "
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
modifiedAt: Date.now()
|
|
2763
|
-
}]
|
|
3219
|
+
type: "RESOLVE_DELETE_PROMPT",
|
|
3220
|
+
session: event.session,
|
|
3221
|
+
confirmedFileNames: [],
|
|
3222
|
+
cancelledFiles: event.files
|
|
2764
3223
|
});
|
|
2765
3224
|
return {
|
|
2766
3225
|
state,
|
|
2767
3226
|
effects
|
|
2768
3227
|
};
|
|
2769
|
-
case "CONFLICTS_RESOLVED":
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
};
|
|
2776
|
-
}
|
|
2777
|
-
if (event.resolution === "remote") {
|
|
2778
|
-
for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
|
|
2779
|
-
type: "DELETE_LOCAL_FILES",
|
|
2780
|
-
names: [conflict.fileName]
|
|
2781
|
-
});
|
|
2782
|
-
else effects.push({
|
|
2783
|
-
type: "WRITE_FILES",
|
|
2784
|
-
files: [{
|
|
2785
|
-
name: conflict.fileName,
|
|
2786
|
-
content: conflict.remoteContent,
|
|
2787
|
-
modifiedAt: conflict.remoteModifiedAt
|
|
2788
|
-
}],
|
|
2789
|
-
silent: true
|
|
2790
|
-
});
|
|
2791
|
-
effects.push(log("success", "Keeping Framer changes"));
|
|
2792
|
-
} else {
|
|
2793
|
-
const localDeletes = [];
|
|
2794
|
-
for (const conflict of state.pendingConflicts) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
|
|
2795
|
-
else effects.push({
|
|
2796
|
-
type: "SEND_MESSAGE",
|
|
2797
|
-
payload: {
|
|
2798
|
-
type: "file-change",
|
|
2799
|
-
fileName: conflict.fileName,
|
|
2800
|
-
content: conflict.localContent
|
|
2801
|
-
}
|
|
2802
|
-
});
|
|
2803
|
-
if (localDeletes.length > 0) effects.push({
|
|
2804
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2805
|
-
fileNames: localDeletes
|
|
2806
|
-
});
|
|
2807
|
-
effects.push(log("success", "Keeping local changes"));
|
|
2808
|
-
}
|
|
2809
|
-
effects.push({ type: "PERSIST_STATE" }, {
|
|
2810
|
-
type: "SYNC_COMPLETE",
|
|
2811
|
-
totalCount: state.pendingConflicts.length,
|
|
2812
|
-
updatedCount: state.pendingConflicts.length,
|
|
2813
|
-
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
|
|
2814
3234
|
});
|
|
2815
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2816
3235
|
return {
|
|
2817
|
-
state: {
|
|
2818
|
-
|
|
2819
|
-
|
|
3236
|
+
state: state.phase === "disconnected" ? state : {
|
|
3237
|
+
phase: "watching",
|
|
3238
|
+
socket: state.socket
|
|
2820
3239
|
},
|
|
2821
3240
|
effects
|
|
2822
3241
|
};
|
|
2823
|
-
}
|
|
2824
3242
|
case "WATCHER_EVENT": {
|
|
2825
3243
|
const { kind, relativePath, content } = event.event;
|
|
2826
|
-
if (state.
|
|
2827
|
-
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}`));
|
|
2828
3246
|
return {
|
|
2829
3247
|
state,
|
|
2830
3248
|
effects
|
|
@@ -2840,14 +3258,30 @@ function transition(state, event) {
|
|
|
2840
3258
|
effects
|
|
2841
3259
|
};
|
|
2842
3260
|
}
|
|
2843
|
-
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({
|
|
2844
3272
|
type: "SEND_LOCAL_CHANGE",
|
|
2845
3273
|
fileName: relativePath,
|
|
2846
3274
|
content
|
|
2847
3275
|
});
|
|
2848
3276
|
break;
|
|
2849
3277
|
case "delete":
|
|
2850
|
-
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}`), {
|
|
2851
3285
|
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2852
3286
|
fileNames: [relativePath]
|
|
2853
3287
|
});
|
|
@@ -2860,7 +3294,8 @@ function transition(state, event) {
|
|
|
2860
3294
|
effects
|
|
2861
3295
|
};
|
|
2862
3296
|
}
|
|
2863
|
-
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}`), {
|
|
2864
3299
|
type: "SEND_FILE_RENAME",
|
|
2865
3300
|
oldFileName: event.event.oldRelativePath,
|
|
2866
3301
|
newFileName: relativePath,
|
|
@@ -2873,28 +3308,24 @@ function transition(state, event) {
|
|
|
2873
3308
|
effects
|
|
2874
3309
|
};
|
|
2875
3310
|
}
|
|
2876
|
-
case "
|
|
2877
|
-
if (state.
|
|
2878
|
-
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`));
|
|
2879
3314
|
return {
|
|
2880
3315
|
state,
|
|
2881
3316
|
effects
|
|
2882
3317
|
};
|
|
2883
3318
|
}
|
|
2884
3319
|
const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
|
|
3320
|
+
const localDeleteConflicts = [];
|
|
2885
3321
|
if (autoResolvedLocal.length > 0) {
|
|
2886
3322
|
effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
|
|
2887
|
-
const
|
|
2888
|
-
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);
|
|
2889
3324
|
else effects.push({
|
|
2890
3325
|
type: "SEND_LOCAL_CHANGE",
|
|
2891
3326
|
fileName: conflict.fileName,
|
|
2892
3327
|
content: conflict.localContent
|
|
2893
3328
|
});
|
|
2894
|
-
if (localDeletes.length > 0) effects.push({
|
|
2895
|
-
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
2896
|
-
fileNames: localDeletes
|
|
2897
|
-
});
|
|
2898
3329
|
}
|
|
2899
3330
|
if (autoResolvedRemote.length > 0) {
|
|
2900
3331
|
effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
|
|
@@ -2909,22 +3340,28 @@ function transition(state, event) {
|
|
|
2909
3340
|
content: conflict.remoteContent,
|
|
2910
3341
|
modifiedAt: conflict.remoteModifiedAt ?? Date.now()
|
|
2911
3342
|
}],
|
|
3343
|
+
echoPolicy: "authoritative",
|
|
2912
3344
|
silent: true
|
|
2913
3345
|
});
|
|
2914
3346
|
}
|
|
2915
|
-
|
|
2916
|
-
|
|
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`), {
|
|
2917
3350
|
type: "REQUEST_CONFLICT_DECISIONS",
|
|
2918
|
-
conflicts:
|
|
3351
|
+
conflicts: conflictsForPrompt
|
|
2919
3352
|
});
|
|
2920
3353
|
return {
|
|
2921
3354
|
state: {
|
|
2922
|
-
|
|
2923
|
-
|
|
3355
|
+
phase: "watching",
|
|
3356
|
+
socket: state.socket
|
|
2924
3357
|
},
|
|
2925
3358
|
effects
|
|
2926
3359
|
};
|
|
2927
3360
|
}
|
|
3361
|
+
if (localDeleteConflicts.length > 0) effects.push({
|
|
3362
|
+
type: "LOCAL_INITIATED_FILE_DELETE",
|
|
3363
|
+
fileNames: localDeleteConflicts.map((conflict) => conflict.fileName)
|
|
3364
|
+
});
|
|
2928
3365
|
const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
|
|
2929
3366
|
effects.push({ type: "PERSIST_STATE" }, {
|
|
2930
3367
|
type: "SYNC_COMPLETE",
|
|
@@ -2932,12 +3369,10 @@ function transition(state, event) {
|
|
|
2932
3369
|
updatedCount: resolvedCount,
|
|
2933
3370
|
unchangedCount: 0
|
|
2934
3371
|
});
|
|
2935
|
-
const { pendingConflicts: _discarded, ...rest } = state;
|
|
2936
3372
|
return {
|
|
2937
3373
|
state: {
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
pendingRemoteChanges: []
|
|
3374
|
+
phase: "watching",
|
|
3375
|
+
socket: state.socket
|
|
2941
3376
|
},
|
|
2942
3377
|
effects
|
|
2943
3378
|
};
|
|
@@ -2950,271 +3385,470 @@ function transition(state, event) {
|
|
|
2950
3385
|
};
|
|
2951
3386
|
}
|
|
2952
3387
|
}
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
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;
|
|
2959
3586
|
switch (effect.type) {
|
|
2960
|
-
case "INIT_WORKSPACE":
|
|
2961
|
-
if (
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
debug(`Files directory: ${config.filesDir}`);
|
|
2973
|
-
await fs.mkdir(config.filesDir, { recursive: true });
|
|
2974
|
-
}
|
|
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 });
|
|
2975
3599
|
return [];
|
|
3600
|
+
}
|
|
2976
3601
|
case "LOAD_PERSISTED_STATE":
|
|
2977
|
-
if (
|
|
2978
|
-
await
|
|
2979
|
-
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")}`);
|
|
2980
3605
|
}
|
|
2981
3606
|
return [];
|
|
2982
|
-
case "LIST_LOCAL_FILES":
|
|
2983
|
-
if (
|
|
2984
|
-
const files = await listFiles(config.filesDir);
|
|
2985
|
-
if (syncState.socket) await sendMessage(syncState.socket, {
|
|
3607
|
+
case "LIST_LOCAL_FILES":
|
|
3608
|
+
if (runtime.workspace.filesDir) await sendToPlugin(syncState.socket, {
|
|
2986
3609
|
type: "file-list",
|
|
2987
|
-
files
|
|
3610
|
+
files: await listFiles(runtime.workspace.filesDir)
|
|
2988
3611
|
});
|
|
2989
3612
|
return [];
|
|
2990
|
-
}
|
|
2991
3613
|
case "DETECT_CONFLICTS": {
|
|
2992
|
-
if (!
|
|
2993
|
-
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles,
|
|
2994
|
-
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());
|
|
2995
3617
|
return [{
|
|
2996
3618
|
type: "CONFLICTS_DETECTED",
|
|
2997
3619
|
conflicts,
|
|
2998
3620
|
safeWrites: writes,
|
|
2999
|
-
localOnly
|
|
3621
|
+
localOnly,
|
|
3622
|
+
remoteTotal: effect.remoteFiles.length
|
|
3000
3623
|
}];
|
|
3001
3624
|
}
|
|
3002
3625
|
case "SEND_MESSAGE":
|
|
3003
|
-
if (
|
|
3004
|
-
|
|
3005
|
-
|
|
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);
|
|
3006
3635
|
return [];
|
|
3007
3636
|
case "WRITE_FILES":
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
await writeRemoteFiles(filesToWrite, config.filesDir, hashTracker, installer ?? void 0);
|
|
3013
|
-
for (const file of filesToWrite) {
|
|
3014
|
-
if (!effect.silent) fileDown(file.name);
|
|
3015
|
-
const remoteTimestamp = file.modifiedAt ?? Date.now();
|
|
3016
|
-
fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
|
|
3017
|
-
}
|
|
3018
|
-
}
|
|
3637
|
+
await writeFiles(effect.files, ctx, {
|
|
3638
|
+
silent: effect.silent,
|
|
3639
|
+
echoPolicy: effect.echoPolicy
|
|
3640
|
+
});
|
|
3019
3641
|
return [];
|
|
3020
3642
|
case "DELETE_LOCAL_FILES":
|
|
3021
|
-
|
|
3022
|
-
await deleteLocalFile(fileName, config.filesDir, hashTracker);
|
|
3023
|
-
fileDelete(fileName);
|
|
3024
|
-
fileMetadataCache.recordDelete(fileName);
|
|
3025
|
-
}
|
|
3643
|
+
await deleteFiles(effect.names, ctx);
|
|
3026
3644
|
return [];
|
|
3027
3645
|
case "REQUEST_CONFLICT_DECISIONS":
|
|
3028
|
-
await
|
|
3646
|
+
await startConflictPrompt(effect.conflicts, ctx);
|
|
3029
3647
|
return [];
|
|
3030
3648
|
case "REQUEST_CONFLICT_VERSIONS": {
|
|
3031
3649
|
if (!syncState.socket) {
|
|
3032
3650
|
warn("Cannot request conflict versions without active socket");
|
|
3033
3651
|
return [];
|
|
3034
3652
|
}
|
|
3035
|
-
const persistedState =
|
|
3036
|
-
const
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
});
|
|
3043
|
-
debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
|
|
3044
|
-
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, {
|
|
3045
3660
|
type: "conflict-version-request",
|
|
3046
|
-
conflicts
|
|
3661
|
+
conflicts
|
|
3047
3662
|
});
|
|
3048
3663
|
return [];
|
|
3049
3664
|
}
|
|
3050
3665
|
case "UPDATE_FILE_METADATA": {
|
|
3051
|
-
if (!
|
|
3052
|
-
const currentContent = await readFileSafe(effect.fileName,
|
|
3053
|
-
const
|
|
3054
|
-
const syncedContent = currentContent ??
|
|
3055
|
-
if (syncedContent !== null)
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
hashTracker.forget(pendingRenameConfirmation.oldFileName);
|
|
3061
|
-
fileMetadataCache.recordDelete(pendingRenameConfirmation.oldFileName);
|
|
3062
|
-
if (currentContent !== null) hashTracker.remember(effect.fileName, currentContent);
|
|
3063
|
-
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);
|
|
3064
3675
|
}
|
|
3065
3676
|
return [];
|
|
3066
3677
|
}
|
|
3067
|
-
case "SEND_LOCAL_CHANGE":
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
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)");
|
|
3071
3697
|
return [];
|
|
3072
3698
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
if (installer) installer.process(effect.fileName, effect.content);
|
|
3086
|
-
} catch (err) {
|
|
3087
|
-
warn(`Failed to push ${effect.fileName}`);
|
|
3088
|
-
}
|
|
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);
|
|
3089
3711
|
return [];
|
|
3090
3712
|
}
|
|
3091
|
-
case "
|
|
3092
|
-
const
|
|
3093
|
-
if (
|
|
3094
|
-
|
|
3095
|
-
hashTracker.clearDelete(effect.oldFileName);
|
|
3096
|
-
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)");
|
|
3097
3717
|
return [];
|
|
3098
3718
|
}
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
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
|
+
});
|
|
3112
3732
|
}
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
});
|
|
3117
|
-
}
|
|
3118
|
-
|
|
3119
|
-
|
|
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);
|
|
3120
3748
|
return [];
|
|
3121
3749
|
}
|
|
3122
|
-
case "
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
hashTracker.forget(fileName);
|
|
3136
|
-
fileMetadataCache.recordDelete(fileName);
|
|
3137
|
-
fileDelete(fileName);
|
|
3138
|
-
}
|
|
3139
|
-
if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
|
|
3140
|
-
type: "file-delete",
|
|
3141
|
-
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
|
|
3142
3763
|
});
|
|
3143
|
-
|
|
3144
|
-
console.warn(`Failed to handle deletion for ${filesToDelete.join(", ")}:`, err);
|
|
3764
|
+
await flushPendingSyncComplete(ctx);
|
|
3145
3765
|
}
|
|
3146
3766
|
return [];
|
|
3147
3767
|
}
|
|
3148
3768
|
case "PERSIST_STATE":
|
|
3149
|
-
await
|
|
3769
|
+
await runtime.metadata.flush();
|
|
3150
3770
|
return [];
|
|
3151
|
-
case "SYNC_COMPLETE":
|
|
3152
|
-
|
|
3153
|
-
if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
|
|
3154
|
-
if (wasDisconnected) {
|
|
3155
|
-
if (didShowDisconnect()) {
|
|
3156
|
-
success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3157
|
-
status("Watching for changes...");
|
|
3158
|
-
}
|
|
3159
|
-
resetDisconnectState();
|
|
3160
|
-
return [];
|
|
3161
|
-
}
|
|
3162
|
-
const relative = config.projectDir ? path.relative(process.cwd(), config.projectDir) : null;
|
|
3163
|
-
const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
|
|
3164
|
-
if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
|
|
3165
|
-
else success(`Syncing to ${relativeDirectory} folder`);
|
|
3166
|
-
else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
|
|
3167
|
-
else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
|
|
3168
|
-
else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
|
|
3169
|
-
if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
|
|
3170
|
-
status("Watching for changes...");
|
|
3771
|
+
case "SYNC_COMPLETE":
|
|
3772
|
+
await applySyncComplete(effect, ctx);
|
|
3171
3773
|
return [];
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
success,
|
|
3178
|
-
debug
|
|
3179
|
-
}[effect.level];
|
|
3180
|
-
logFn(effect.message);
|
|
3774
|
+
case "LOG":
|
|
3775
|
+
emitLog({
|
|
3776
|
+
level: effect.level,
|
|
3777
|
+
message: effect.message
|
|
3778
|
+
});
|
|
3181
3779
|
return [];
|
|
3182
|
-
}
|
|
3183
3780
|
}
|
|
3184
3781
|
}
|
|
3185
3782
|
/**
|
|
3186
3783
|
* Starts the sync controller with the given configuration
|
|
3187
3784
|
*/
|
|
3188
3785
|
async function start(config) {
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
let installer = null;
|
|
3786
|
+
const runtime = new SyncRuntime();
|
|
3787
|
+
let isShuttingDown = false;
|
|
3788
|
+
let pendingDependencyVersions = null;
|
|
3193
3789
|
let syncState = {
|
|
3194
|
-
|
|
3195
|
-
socket: null
|
|
3196
|
-
pendingRemoteChanges: []
|
|
3790
|
+
phase: "disconnected",
|
|
3791
|
+
socket: null
|
|
3197
3792
|
};
|
|
3198
|
-
const
|
|
3199
|
-
|
|
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) {
|
|
3200
3833
|
const socketState = syncState.socket?.readyState;
|
|
3201
|
-
debug(`[STATE] Processing event: ${event.type} (
|
|
3202
|
-
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
|
+
});
|
|
3203
3840
|
syncState = result.state;
|
|
3204
3841
|
if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
|
|
3205
3842
|
for (const effect of result.effects) {
|
|
3206
3843
|
const currentSocketState = syncState.socket?.readyState;
|
|
3207
3844
|
if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
|
|
3208
|
-
const followUpEvents = await
|
|
3845
|
+
const followUpEvents = await applyEffect(effect, {
|
|
3209
3846
|
config,
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
fileMetadataCache,
|
|
3213
|
-
pendingRenameConfirmations,
|
|
3214
|
-
userActions,
|
|
3847
|
+
runtime,
|
|
3848
|
+
shutdown,
|
|
3215
3849
|
syncState
|
|
3216
3850
|
});
|
|
3217
|
-
for (const followUpEvent of followUpEvents) await
|
|
3851
|
+
for (const followUpEvent of followUpEvents) await processEventInner(followUpEvent);
|
|
3218
3852
|
}
|
|
3219
3853
|
}
|
|
3220
3854
|
const certs = await getOrCreateCerts();
|
|
@@ -3223,7 +3857,7 @@ async function start(config) {
|
|
|
3223
3857
|
info("");
|
|
3224
3858
|
info("To fix this:");
|
|
3225
3859
|
info(" 1. Re-run this command — certificate generation is often a one-time issue");
|
|
3226
|
-
info(` 2. Manually delete "${
|
|
3860
|
+
info(` 2. Manually delete "${CERT_DIR}" and try again`);
|
|
3227
3861
|
info("");
|
|
3228
3862
|
throw new Error("TLS certificate generation failed");
|
|
3229
3863
|
}
|
|
@@ -3239,17 +3873,23 @@ async function start(config) {
|
|
|
3239
3873
|
return;
|
|
3240
3874
|
}
|
|
3241
3875
|
(async () => {
|
|
3242
|
-
|
|
3243
|
-
if (syncState.
|
|
3876
|
+
runtime.disconnectUi.cancelNotice();
|
|
3877
|
+
if (syncState.phase !== "disconnected") {
|
|
3244
3878
|
if (syncState.socket === client) {
|
|
3245
|
-
|
|
3879
|
+
await processEvent({
|
|
3880
|
+
type: "RESEND_SYNC_STATUS",
|
|
3881
|
+
status: runtime.lastEmittedSyncStatus ?? "initial_sync"
|
|
3882
|
+
});
|
|
3246
3883
|
return;
|
|
3247
3884
|
}
|
|
3248
|
-
debug(`New handshake received
|
|
3249
|
-
|
|
3885
|
+
debug(`New handshake received (phase=${syncState.phase}), resetting sync state`);
|
|
3886
|
+
runtime.clearPendingRenames();
|
|
3887
|
+
runtime.clearEmittedSyncStatus();
|
|
3888
|
+
runtime.cleanupUserActions();
|
|
3250
3889
|
await processEvent({ type: "DISCONNECT" });
|
|
3251
3890
|
}
|
|
3252
|
-
|
|
3891
|
+
runtime.mintConnectionId();
|
|
3892
|
+
if (!runtime.disconnectUi.wasRecentlyDisconnected() && !runtime.disconnectUi.didShowNotice()) success(`Connected to ${message.projectName}`);
|
|
3253
3893
|
await processEvent({
|
|
3254
3894
|
type: "HANDSHAKE",
|
|
3255
3895
|
socket: client,
|
|
@@ -3258,18 +3898,20 @@ async function start(config) {
|
|
|
3258
3898
|
projectName: message.projectName
|
|
3259
3899
|
}
|
|
3260
3900
|
});
|
|
3261
|
-
if (
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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
|
|
3265
3907
|
});
|
|
3266
|
-
await installer.initialize();
|
|
3908
|
+
await runtime.installer.initialize();
|
|
3267
3909
|
startWatcher();
|
|
3268
3910
|
}
|
|
3269
3911
|
})();
|
|
3270
3912
|
});
|
|
3271
3913
|
async function handleMessage(message) {
|
|
3272
|
-
if (!
|
|
3914
|
+
if (!runtime.workspace.projectDir || !runtime.installer) {
|
|
3273
3915
|
warn("Received message before handshake completed - ignoring");
|
|
3274
3916
|
return;
|
|
3275
3917
|
}
|
|
@@ -3292,8 +3934,7 @@ async function start(config) {
|
|
|
3292
3934
|
name: message.fileName,
|
|
3293
3935
|
content: message.content,
|
|
3294
3936
|
modifiedAt: Date.now()
|
|
3295
|
-
}
|
|
3296
|
-
fileMeta: fileMetadataCache.get(message.fileName)
|
|
3937
|
+
}
|
|
3297
3938
|
};
|
|
3298
3939
|
break;
|
|
3299
3940
|
case "file-delete":
|
|
@@ -3302,25 +3943,20 @@ async function start(config) {
|
|
|
3302
3943
|
fileName
|
|
3303
3944
|
});
|
|
3304
3945
|
return;
|
|
3305
|
-
case "delete-confirmed":
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
return;
|
|
3313
|
-
}
|
|
3946
|
+
case "delete-confirmed":
|
|
3947
|
+
event = {
|
|
3948
|
+
type: "DELETE_CONFIRMED",
|
|
3949
|
+
session: message.session,
|
|
3950
|
+
fileNames: message.fileNames
|
|
3951
|
+
};
|
|
3952
|
+
break;
|
|
3314
3953
|
case "delete-cancelled":
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
});
|
|
3322
|
-
}
|
|
3323
|
-
return;
|
|
3954
|
+
event = {
|
|
3955
|
+
type: "DELETE_CANCELLED",
|
|
3956
|
+
session: message.session,
|
|
3957
|
+
files: message.files
|
|
3958
|
+
};
|
|
3959
|
+
break;
|
|
3324
3960
|
case "file-synced":
|
|
3325
3961
|
event = {
|
|
3326
3962
|
type: "FILE_SYNCED_CONFIRMATION",
|
|
@@ -3329,21 +3965,34 @@ async function start(config) {
|
|
|
3329
3965
|
};
|
|
3330
3966
|
break;
|
|
3331
3967
|
case "error":
|
|
3332
|
-
if (message.fileName)
|
|
3968
|
+
if (message.fileName) runtime.completePendingRename(normalizeCodeFilePathWithExtension(message.fileName));
|
|
3333
3969
|
warn(message.message);
|
|
3334
3970
|
return;
|
|
3335
3971
|
case "conflicts-resolved":
|
|
3336
3972
|
event = {
|
|
3337
3973
|
type: "CONFLICTS_RESOLVED",
|
|
3338
|
-
|
|
3974
|
+
session: message.session,
|
|
3975
|
+
resolution: message.resolution,
|
|
3976
|
+
fileNames: message.fileNames
|
|
3339
3977
|
};
|
|
3340
3978
|
break;
|
|
3341
3979
|
case "conflict-version-response":
|
|
3342
3980
|
event = {
|
|
3343
|
-
type: "
|
|
3981
|
+
type: "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS",
|
|
3344
3982
|
versions: message.versions
|
|
3345
3983
|
};
|
|
3346
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
|
+
}
|
|
3347
3996
|
default:
|
|
3348
3997
|
warn(`Unhandled message type: ${message.type}`);
|
|
3349
3998
|
return;
|
|
@@ -3360,26 +4009,42 @@ async function start(config) {
|
|
|
3360
4009
|
})();
|
|
3361
4010
|
});
|
|
3362
4011
|
connection.on("disconnect", (client) => {
|
|
4012
|
+
if (isShuttingDown) {
|
|
4013
|
+
debug("[STATE] Ignoring disconnect during shutdown");
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
3363
4016
|
if (syncState.socket !== client) {
|
|
3364
4017
|
debug("[STATE] Ignoring disconnect from stale socket");
|
|
3365
4018
|
return;
|
|
3366
4019
|
}
|
|
3367
|
-
|
|
4020
|
+
runtime.disconnectUi.scheduleNotice(() => {
|
|
3368
4021
|
status("Disconnected, waiting to reconnect...");
|
|
3369
4022
|
});
|
|
3370
4023
|
(async () => {
|
|
3371
|
-
|
|
4024
|
+
runtime.clearPendingRenames();
|
|
3372
4025
|
await processEvent({ type: "DISCONNECT" });
|
|
3373
|
-
|
|
4026
|
+
runtime.clearEmittedSyncStatus();
|
|
4027
|
+
runtime.cleanupUserActions();
|
|
3374
4028
|
})();
|
|
3375
4029
|
});
|
|
3376
4030
|
connection.on("error", (err) => {
|
|
3377
4031
|
error("Error on WebSocket connection:", err);
|
|
3378
4032
|
});
|
|
3379
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
|
+
};
|
|
3380
4045
|
const startWatcher = () => {
|
|
3381
|
-
if (!
|
|
3382
|
-
watcher = initWatcher(
|
|
4046
|
+
if (!runtime.workspace.filesDir || watcher) return;
|
|
4047
|
+
watcher = initWatcher(runtime.workspace.filesDir);
|
|
3383
4048
|
watcher.on("change", (event) => {
|
|
3384
4049
|
processEvent({
|
|
3385
4050
|
type: "WATCHER_EVENT",
|
|
@@ -3391,8 +4056,7 @@ async function start(config) {
|
|
|
3391
4056
|
console.log();
|
|
3392
4057
|
status("Shutting down...");
|
|
3393
4058
|
(async () => {
|
|
3394
|
-
|
|
3395
|
-
connection.close();
|
|
4059
|
+
await shutdown();
|
|
3396
4060
|
process.exit(0);
|
|
3397
4061
|
})();
|
|
3398
4062
|
});
|
|
@@ -3408,6 +4072,11 @@ async function start(config) {
|
|
|
3408
4072
|
*/
|
|
3409
4073
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
3410
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
|
+
}
|
|
3411
4080
|
program.exitOverride((err) => {
|
|
3412
4081
|
if (err.code === "commander.missingArgument") {
|
|
3413
4082
|
console.error("Missing Project ID. Copy command via Code Link Plugin.");
|
|
@@ -3415,7 +4084,7 @@ program.exitOverride((err) => {
|
|
|
3415
4084
|
}
|
|
3416
4085
|
throw err;
|
|
3417
4086
|
});
|
|
3418
|
-
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) => {
|
|
3419
4088
|
if (!projectHash) {
|
|
3420
4089
|
const detected = await getProjectHashFromCwd();
|
|
3421
4090
|
if (detected) projectHash = detected;
|
|
@@ -3442,7 +4111,8 @@ program.name("framer-code-link").description("Sync Framer code components to you
|
|
|
3442
4111
|
projectDir: null,
|
|
3443
4112
|
filesDir: null,
|
|
3444
4113
|
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
3445
|
-
|
|
4114
|
+
npmStrategy: options.unsupportedNpm,
|
|
4115
|
+
once: options.once ?? false,
|
|
3446
4116
|
explicitDirectory: options.dir,
|
|
3447
4117
|
explicitName: options.name
|
|
3448
4118
|
};
|