framer-code-link 0.18.0 → 0.21.0-alpha.0

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