framer-code-link 0.16.0 → 0.17.1-alpha.0

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