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

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