framer-code-link 0.17.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 +1902 -752
  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
+ }
1818
2161
  }
1819
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;
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;
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,39 +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;
3013
+ const { conflicts, safeWrites, localOnly, remoteTotal } = event;
2198
3014
  if (safeWrites.length > 0) {
2199
3015
  effects.push(log("debug", `Applying ${safeWrites.length} safe writes`));
2200
- if (wasRecentlyDisconnected()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
3016
+ if (read.wasRecentlyDisconnected?.()) effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`));
2201
3017
  effects.push({
2202
3018
  type: "WRITE_FILES",
2203
3019
  files: safeWrites,
2204
- silent: true
3020
+ silent: true,
3021
+ echoPolicy: "authoritative"
2205
3022
  });
2206
3023
  }
2207
3024
  if (localOnly.length > 0) {
2208
3025
  effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`));
2209
3026
  for (const file of localOnly) effects.push({
2210
- type: "SEND_MESSAGE",
2211
- payload: {
2212
- type: "file-change",
2213
- fileName: file.name,
2214
- content: file.content
2215
- }
3027
+ type: "SEND_LOCAL_CHANGE",
3028
+ fileName: file.name,
3029
+ content: file.content
2216
3030
  });
2217
3031
  }
2218
3032
  if (conflicts.length > 0) {
@@ -2222,14 +3036,13 @@ function transition(state, event) {
2222
3036
  });
2223
3037
  return {
2224
3038
  state: {
2225
- ...state,
2226
- mode: "conflict_resolution",
3039
+ phase: "conflict_resolution",
3040
+ socket: state.socket,
2227
3041
  pendingConflicts: conflicts
2228
3042
  },
2229
3043
  effects
2230
3044
  };
2231
3045
  }
2232
- const remoteTotal = state.pendingRemoteChanges.length;
2233
3046
  const totalCount = remoteTotal + localOnly.length;
2234
3047
  const updatedCount = safeWrites.length + localOnly.length;
2235
3048
  const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
@@ -2241,24 +3054,51 @@ function transition(state, event) {
2241
3054
  });
2242
3055
  return {
2243
3056
  state: {
2244
- ...state,
2245
- mode: "watching",
2246
- pendingRemoteChanges: []
3057
+ phase: "watching",
3058
+ socket: state.socket
2247
3059
  },
2248
3060
  effects
2249
3061
  };
2250
3062
  }
2251
- case "REMOTE_FILE_CHANGE": {
2252
- const validation = validateIncomingChange(event.fileMeta, state.mode);
2253
- 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") {
2254
3094
  effects.push(log("debug", `Ignoring file change during sync: ${event.file.name}`));
2255
3095
  return {
2256
3096
  state,
2257
3097
  effects
2258
3098
  };
2259
3099
  }
2260
- if (validation.action === "reject") {
2261
- 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)`));
2262
3102
  return {
2263
3103
  state,
2264
3104
  effects
@@ -2267,15 +3107,43 @@ function transition(state, event) {
2267
3107
  effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
2268
3108
  type: "WRITE_FILES",
2269
3109
  files: [event.file],
2270
- skipEcho: true
3110
+ echoPolicy: "skip-expected-echoes"
2271
3111
  });
2272
3112
  return {
2273
3113
  state,
2274
3114
  effects
2275
3115
  };
2276
- }
2277
3116
  case "REMOTE_FILE_DELETE":
2278
- 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") {
2279
3147
  effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
2280
3148
  return {
2281
3149
  state,
@@ -2290,88 +3158,46 @@ function transition(state, event) {
2290
3158
  state,
2291
3159
  effects
2292
3160
  };
2293
- case "LOCAL_DELETE_APPROVED":
2294
- effects.push(log("debug", `Delete confirmed: ${event.fileName}`), {
2295
- type: "DELETE_LOCAL_FILES",
2296
- names: [event.fileName]
2297
- }, { 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
+ });
2298
3168
  return {
2299
3169
  state,
2300
3170
  effects
2301
3171
  };
2302
- case "LOCAL_DELETE_REJECTED":
2303
- effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
3172
+ case "DELETE_CANCELLED":
2304
3173
  effects.push({
2305
- type: "WRITE_FILES",
2306
- files: [{
2307
- name: event.fileName,
2308
- content: event.content,
2309
- modifiedAt: Date.now()
2310
- }]
3174
+ type: "RESOLVE_DELETE_PROMPT",
3175
+ session: event.session,
3176
+ confirmedFileNames: [],
3177
+ cancelledFiles: event.files
2311
3178
  });
2312
3179
  return {
2313
3180
  state,
2314
3181
  effects
2315
3182
  };
2316
- case "CONFLICTS_RESOLVED": {
2317
- if (state.mode !== "conflict_resolution") {
2318
- effects.push(log("warn", `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`));
2319
- return {
2320
- state,
2321
- effects
2322
- };
2323
- }
2324
- if (event.resolution === "remote") {
2325
- for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
2326
- type: "DELETE_LOCAL_FILES",
2327
- names: [conflict.fileName]
2328
- });
2329
- else effects.push({
2330
- type: "WRITE_FILES",
2331
- files: [{
2332
- name: conflict.fileName,
2333
- content: conflict.remoteContent,
2334
- modifiedAt: conflict.remoteModifiedAt
2335
- }],
2336
- silent: true
2337
- });
2338
- effects.push(log("success", "Keeping Framer changes"));
2339
- } else {
2340
- const localDeletes = [];
2341
- for (const conflict of state.pendingConflicts) if (conflict.localContent === null) localDeletes.push(conflict.fileName);
2342
- else effects.push({
2343
- type: "SEND_MESSAGE",
2344
- payload: {
2345
- type: "file-change",
2346
- fileName: conflict.fileName,
2347
- content: conflict.localContent
2348
- }
2349
- });
2350
- if (localDeletes.length > 0) effects.push({
2351
- type: "LOCAL_INITIATED_FILE_DELETE",
2352
- fileNames: localDeletes
2353
- });
2354
- effects.push(log("success", "Keeping local changes"));
2355
- }
2356
- effects.push({ type: "PERSIST_STATE" }, {
2357
- type: "SYNC_COMPLETE",
2358
- totalCount: state.pendingConflicts.length,
2359
- updatedCount: state.pendingConflicts.length,
2360
- 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
2361
3189
  });
2362
- const { pendingConflicts: _discarded, ...rest } = state;
2363
3190
  return {
2364
- state: {
2365
- ...rest,
2366
- mode: "watching"
3191
+ state: state.phase === "disconnected" ? state : {
3192
+ phase: "watching",
3193
+ socket: state.socket
2367
3194
  },
2368
3195
  effects
2369
3196
  };
2370
- }
2371
3197
  case "WATCHER_EVENT": {
2372
3198
  const { kind, relativePath, content } = event.event;
2373
- if (state.mode !== "watching") {
2374
- 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}`));
2375
3201
  return {
2376
3202
  state,
2377
3203
  effects
@@ -2387,46 +3213,74 @@ function transition(state, event) {
2387
3213
  effects
2388
3214
  };
2389
3215
  }
2390
- 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({
2391
3227
  type: "SEND_LOCAL_CHANGE",
2392
3228
  fileName: relativePath,
2393
3229
  content
2394
3230
  });
2395
3231
  break;
2396
3232
  case "delete":
2397
- 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}`), {
2398
3240
  type: "LOCAL_INITIATED_FILE_DELETE",
2399
3241
  fileNames: [relativePath]
2400
3242
  });
2401
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;
2402
3260
  }
2403
3261
  return {
2404
3262
  state,
2405
3263
  effects
2406
3264
  };
2407
3265
  }
2408
- case "CONFLICT_VERSION_RESPONSE": {
2409
- if (state.mode !== "conflict_resolution") {
2410
- 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`));
2411
3269
  return {
2412
3270
  state,
2413
3271
  effects
2414
3272
  };
2415
3273
  }
2416
3274
  const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
3275
+ const localDeleteConflicts = [];
2417
3276
  if (autoResolvedLocal.length > 0) {
2418
3277
  effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
2419
- const localDeletes = [];
2420
- 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);
2421
3279
  else effects.push({
2422
3280
  type: "SEND_LOCAL_CHANGE",
2423
3281
  fileName: conflict.fileName,
2424
3282
  content: conflict.localContent
2425
3283
  });
2426
- if (localDeletes.length > 0) effects.push({
2427
- type: "LOCAL_INITIATED_FILE_DELETE",
2428
- fileNames: localDeletes
2429
- });
2430
3284
  }
2431
3285
  if (autoResolvedRemote.length > 0) {
2432
3286
  effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
@@ -2441,22 +3295,28 @@ function transition(state, event) {
2441
3295
  content: conflict.remoteContent,
2442
3296
  modifiedAt: conflict.remoteModifiedAt ?? Date.now()
2443
3297
  }],
3298
+ echoPolicy: "authoritative",
2444
3299
  silent: true
2445
3300
  });
2446
3301
  }
2447
- if (remainingConflicts.length > 0) {
2448
- 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`), {
2449
3305
  type: "REQUEST_CONFLICT_DECISIONS",
2450
- conflicts: remainingConflicts
3306
+ conflicts: conflictsForPrompt
2451
3307
  });
2452
3308
  return {
2453
3309
  state: {
2454
- ...state,
2455
- pendingConflicts: remainingConflicts
3310
+ phase: "watching",
3311
+ socket: state.socket
2456
3312
  },
2457
3313
  effects
2458
3314
  };
2459
3315
  }
3316
+ if (localDeleteConflicts.length > 0) effects.push({
3317
+ type: "LOCAL_INITIATED_FILE_DELETE",
3318
+ fileNames: localDeleteConflicts.map((conflict) => conflict.fileName)
3319
+ });
2460
3320
  const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
2461
3321
  effects.push({ type: "PERSIST_STATE" }, {
2462
3322
  type: "SYNC_COMPLETE",
@@ -2464,12 +3324,10 @@ function transition(state, event) {
2464
3324
  updatedCount: resolvedCount,
2465
3325
  unchangedCount: 0
2466
3326
  });
2467
- const { pendingConflicts: _discarded, ...rest } = state;
2468
3327
  return {
2469
3328
  state: {
2470
- ...rest,
2471
- mode: "watching",
2472
- pendingRemoteChanges: []
3329
+ phase: "watching",
3330
+ socket: state.socket
2473
3331
  },
2474
3332
  effects
2475
3333
  };
@@ -2482,234 +3340,484 @@ function transition(state, event) {
2482
3340
  };
2483
3341
  }
2484
3342
  }
2485
- /**
2486
- * Effect executor - interprets effects and calls helpers
2487
- * Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS)
2488
- */
2489
- async function executeEffect(effect, context) {
2490
- 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;
2491
3541
  switch (effect.type) {
2492
- case "INIT_WORKSPACE":
2493
- if (!config.projectDir) {
2494
- const projectName = config.explicitName ?? effect.projectInfo.projectName;
2495
- const directoryInfo = await findOrCreateProjectDirectory({
2496
- projectHash: config.projectHash,
2497
- projectName,
2498
- explicitDirectory: config.explicitDirectory
2499
- });
2500
- config.projectDir = directoryInfo.directory;
2501
- config.projectDirCreated = directoryInfo.created;
2502
- if (directoryInfo.nameCollision) warn(`Folder ${projectName} already exists`);
2503
- config.filesDir = `${config.projectDir}/files`;
2504
- debug(`Files directory: ${config.filesDir}`);
2505
- await fs.mkdir(config.filesDir, { recursive: true });
2506
- }
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 });
2507
3554
  return [];
3555
+ }
2508
3556
  case "LOAD_PERSISTED_STATE":
2509
- if (config.projectDir) {
2510
- await fileMetadataCache.initialize(config.projectDir);
2511
- 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")}`);
2512
3560
  }
2513
3561
  return [];
2514
- case "LIST_LOCAL_FILES": {
2515
- if (!config.filesDir) return [];
2516
- const files = await listFiles(config.filesDir);
2517
- if (syncState.socket) await sendMessage(syncState.socket, {
3562
+ case "LIST_LOCAL_FILES":
3563
+ if (runtime.workspace.filesDir) await sendToPlugin(syncState.socket, {
2518
3564
  type: "file-list",
2519
- files
3565
+ files: await listFiles(runtime.workspace.filesDir)
2520
3566
  });
2521
3567
  return [];
2522
- }
2523
3568
  case "DETECT_CONFLICTS": {
2524
- if (!config.filesDir) return [];
2525
- const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles, config.filesDir, { persistedState: fileMetadataCache.getPersistedState() });
2526
- 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());
2527
3572
  return [{
2528
3573
  type: "CONFLICTS_DETECTED",
2529
3574
  conflicts,
2530
3575
  safeWrites: writes,
2531
- localOnly
3576
+ localOnly,
3577
+ remoteTotal: effect.remoteFiles.length
2532
3578
  }];
2533
3579
  }
2534
3580
  case "SEND_MESSAGE":
2535
- if (syncState.socket) {
2536
- if (!await sendMessage(syncState.socket, effect.payload)) warn(`Failed to send message: ${effect.payload.type}`);
2537
- } 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);
2538
3590
  return [];
2539
3591
  case "WRITE_FILES":
2540
- if (config.filesDir) {
2541
- const filesToWrite = effect.skipEcho === true ? filterEchoedFiles(effect.files, hashTracker) : effect.files;
2542
- if (effect.skipEcho && filesToWrite.length !== effect.files.length) debug(`Skipped ${pluralize(effect.files.length - filesToWrite.length, "echoed change")}`);
2543
- if (filesToWrite.length === 0) return [];
2544
- await writeRemoteFiles(filesToWrite, config.filesDir, hashTracker, installer ?? void 0);
2545
- for (const file of filesToWrite) {
2546
- if (!effect.silent) fileDown(file.name);
2547
- const remoteTimestamp = file.modifiedAt ?? Date.now();
2548
- fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
2549
- }
2550
- }
3592
+ await writeFiles(effect.files, ctx, {
3593
+ silent: effect.silent,
3594
+ echoPolicy: effect.echoPolicy
3595
+ });
2551
3596
  return [];
2552
3597
  case "DELETE_LOCAL_FILES":
2553
- if (config.filesDir) for (const fileName of effect.names) {
2554
- await deleteLocalFile(fileName, config.filesDir, hashTracker);
2555
- fileDelete(fileName);
2556
- fileMetadataCache.recordDelete(fileName);
2557
- }
3598
+ await deleteFiles(effect.names, ctx);
2558
3599
  return [];
2559
3600
  case "REQUEST_CONFLICT_DECISIONS":
2560
- await userActions.requestConflictDecisions(syncState.socket, effect.conflicts);
3601
+ await startConflictPrompt(effect.conflicts, ctx);
2561
3602
  return [];
2562
3603
  case "REQUEST_CONFLICT_VERSIONS": {
2563
3604
  if (!syncState.socket) {
2564
3605
  warn("Cannot request conflict versions without active socket");
2565
3606
  return [];
2566
3607
  }
2567
- const persistedState = fileMetadataCache.getPersistedState();
2568
- const versionRequests = effect.conflicts.map((conflict) => {
2569
- const persisted = persistedState.get(conflict.fileName);
2570
- return {
2571
- fileName: conflict.fileName,
2572
- lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp
2573
- };
2574
- });
2575
- debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
2576
- 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, {
2577
3615
  type: "conflict-version-request",
2578
- conflicts: versionRequests
3616
+ conflicts
2579
3617
  });
2580
3618
  return [];
2581
3619
  }
2582
3620
  case "UPDATE_FILE_METADATA": {
2583
- if (!config.filesDir || !config.projectDir) return [];
2584
- const currentContent = await readFileSafe(effect.fileName, config.filesDir);
2585
- if (currentContent !== null) {
2586
- const contentHash = hashFileContent(currentContent);
2587
- 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);
2588
3630
  }
2589
3631
  return [];
2590
3632
  }
2591
- case "SEND_LOCAL_CHANGE": {
2592
- const contentHash = hashFileContent(effect.content);
2593
- if (fileMetadataCache.get(effect.fileName)?.lastSyncedHash === contentHash) {
2594
- 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)");
2595
3652
  return [];
2596
3653
  }
2597
- if (hashTracker.shouldSkip(effect.fileName, effect.content)) return [];
2598
- debug(`Local change detected: ${effect.fileName}`);
2599
- try {
2600
- if (syncState.socket) {
2601
- await sendMessage(syncState.socket, {
2602
- type: "file-change",
2603
- fileName: effect.fileName,
2604
- 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
2605
3686
  });
2606
- fileUp(effect.fileName);
2607
3687
  }
2608
- hashTracker.remember(effect.fileName, effect.content);
2609
- if (installer) installer.process(effect.fileName, effect.content);
2610
- } catch (err) {
2611
- warn(`Failed to push ${effect.fileName}`);
2612
- }
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);
2613
3703
  return [];
2614
3704
  }
2615
- case "LOCAL_INITIATED_FILE_DELETE": {
2616
- const filesToDelete = effect.fileNames.filter((fileName) => {
2617
- const shouldSkip = hashTracker.shouldSkipDelete(fileName);
2618
- if (shouldSkip) hashTracker.clearDelete(fileName);
2619
- return !shouldSkip;
2620
- });
2621
- if (filesToDelete.length === 0) return [];
2622
- try {
2623
- const confirmedFiles = await userActions.requestDeleteDecision(syncState.socket, {
2624
- fileNames: filesToDelete,
2625
- requireConfirmation: !config.dangerouslyAutoDelete
2626
- });
2627
- for (const fileName of confirmedFiles) {
2628
- hashTracker.forget(fileName);
2629
- fileMetadataCache.recordDelete(fileName);
2630
- fileDelete(fileName);
2631
- }
2632
- if (confirmedFiles.length > 0 && syncState.socket) await sendMessage(syncState.socket, {
2633
- type: "file-delete",
2634
- 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
2635
3718
  });
2636
- } catch (err) {
2637
- console.warn(`Failed to handle deletion for ${filesToDelete.join(", ")}:`, err);
3719
+ await flushPendingSyncComplete(ctx);
2638
3720
  }
2639
3721
  return [];
2640
3722
  }
2641
3723
  case "PERSIST_STATE":
2642
- await fileMetadataCache.flush();
3724
+ await runtime.metadata.flush();
2643
3725
  return [];
2644
- case "SYNC_COMPLETE": {
2645
- const wasDisconnected = wasRecentlyDisconnected();
2646
- if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2647
- if (wasDisconnected) {
2648
- if (didShowDisconnect()) {
2649
- success(`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2650
- status("Watching for changes...");
2651
- }
2652
- resetDisconnectState();
2653
- return [];
2654
- }
2655
- const relative = config.projectDir ? path.relative(process.cwd(), config.projectDir) : null;
2656
- const relativeDirectory = relative != null ? relative ? "./" + relative : "." : null;
2657
- if (effect.totalCount === 0 && relativeDirectory) if (config.projectDirCreated) success(`Created ${relativeDirectory} folder`);
2658
- else success(`Syncing to ${relativeDirectory} folder`);
2659
- else if (relativeDirectory && config.projectDirCreated) success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`);
2660
- else if (relativeDirectory) success(`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`);
2661
- else success(`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2662
- if (config.projectDirCreated && config.projectDir) tryGitInit(config.projectDir);
2663
- status("Watching for changes...");
3726
+ case "SYNC_COMPLETE":
3727
+ await applySyncComplete(effect, ctx);
2664
3728
  return [];
2665
- }
2666
- case "LOG": {
2667
- const logFn = {
2668
- info,
2669
- warn,
2670
- success,
2671
- debug
2672
- }[effect.level];
2673
- logFn(effect.message);
3729
+ case "LOG":
3730
+ emitLog({
3731
+ level: effect.level,
3732
+ message: effect.message
3733
+ });
2674
3734
  return [];
2675
- }
2676
3735
  }
2677
3736
  }
2678
3737
  /**
2679
3738
  * Starts the sync controller with the given configuration
2680
3739
  */
2681
3740
  async function start(config) {
2682
- status("Waiting for Plugin connection...");
2683
- const hashTracker = createHashTracker();
2684
- const fileMetadataCache = new FileMetadataCache();
2685
- let installer = null;
3741
+ const runtime = new SyncRuntime();
3742
+ let isShuttingDown = false;
3743
+ let pendingDependencyVersions = null;
2686
3744
  let syncState = {
2687
- mode: "disconnected",
2688
- socket: null,
2689
- pendingRemoteChanges: []
3745
+ phase: "disconnected",
3746
+ socket: null
2690
3747
  };
2691
- const userActions = new PluginUserPromptCoordinator();
2692
- 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) {
2693
3788
  const socketState = syncState.socket?.readyState;
2694
- debug(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
2695
- 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
+ });
2696
3795
  syncState = result.state;
2697
3796
  if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
2698
3797
  for (const effect of result.effects) {
2699
3798
  const currentSocketState = syncState.socket?.readyState;
2700
3799
  if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
2701
- const followUpEvents = await executeEffect(effect, {
3800
+ const followUpEvents = await applyEffect(effect, {
2702
3801
  config,
2703
- hashTracker,
2704
- installer,
2705
- fileMetadataCache,
2706
- userActions,
3802
+ runtime,
3803
+ shutdown,
2707
3804
  syncState
2708
3805
  });
2709
- for (const followUpEvent of followUpEvents) await processEvent(followUpEvent);
3806
+ for (const followUpEvent of followUpEvents) await processEventInner(followUpEvent);
2710
3807
  }
2711
3808
  }
2712
- 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);
2713
3821
  connection.on("handshake", (client, message) => {
2714
3822
  debug(`Received handshake: ${message.projectName} (${message.projectId})`);
2715
3823
  const expectedShort = shortProjectHash(config.projectHash);
@@ -2720,16 +3828,23 @@ async function start(config) {
2720
3828
  return;
2721
3829
  }
2722
3830
  (async () => {
2723
- cancelDisconnectMessage();
2724
- if (syncState.mode !== "disconnected") {
3831
+ runtime.disconnectUi.cancelNotice();
3832
+ if (syncState.phase !== "disconnected") {
2725
3833
  if (syncState.socket === client) {
2726
- 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
+ });
2727
3838
  return;
2728
3839
  }
2729
- 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();
2730
3844
  await processEvent({ type: "DISCONNECT" });
2731
3845
  }
2732
- if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
3846
+ runtime.mintConnectionId();
3847
+ if (!runtime.disconnectUi.wasRecentlyDisconnected() && !runtime.disconnectUi.didShowNotice()) success(`Connected to ${message.projectName}`);
2733
3848
  await processEvent({
2734
3849
  type: "HANDSHAKE",
2735
3850
  socket: client,
@@ -2738,18 +3853,20 @@ async function start(config) {
2738
3853
  projectName: message.projectName
2739
3854
  }
2740
3855
  });
2741
- if (config.projectDir && !installer) {
2742
- installer = new Installer({
2743
- projectDir: config.projectDir,
2744
- 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
2745
3862
  });
2746
- await installer.initialize();
3863
+ await runtime.installer.initialize();
2747
3864
  startWatcher();
2748
3865
  }
2749
3866
  })();
2750
3867
  });
2751
3868
  async function handleMessage(message) {
2752
- if (!config.projectDir || !installer) {
3869
+ if (!runtime.workspace.projectDir || !runtime.installer) {
2753
3870
  warn("Received message before handshake completed - ignoring");
2754
3871
  return;
2755
3872
  }
@@ -2772,8 +3889,7 @@ async function start(config) {
2772
3889
  name: message.fileName,
2773
3890
  content: message.content,
2774
3891
  modifiedAt: Date.now()
2775
- },
2776
- fileMeta: fileMetadataCache.get(message.fileName)
3892
+ }
2777
3893
  };
2778
3894
  break;
2779
3895
  case "file-delete":
@@ -2782,25 +3898,20 @@ async function start(config) {
2782
3898
  fileName
2783
3899
  });
2784
3900
  return;
2785
- case "delete-confirmed": {
2786
- const unmatched = [];
2787
- for (const fileName of message.fileNames) if (!userActions.handleConfirmation(`delete:${fileName}`, true)) unmatched.push(fileName);
2788
- for (const fileName of unmatched) await processEvent({
2789
- type: "LOCAL_DELETE_APPROVED",
2790
- fileName
2791
- });
2792
- return;
2793
- }
3901
+ case "delete-confirmed":
3902
+ event = {
3903
+ type: "DELETE_CONFIRMED",
3904
+ session: message.session,
3905
+ fileNames: message.fileNames
3906
+ };
3907
+ break;
2794
3908
  case "delete-cancelled":
2795
- for (const file of message.files) {
2796
- userActions.handleConfirmation(`delete:${file.fileName}`, false);
2797
- await processEvent({
2798
- type: "LOCAL_DELETE_REJECTED",
2799
- fileName: file.fileName,
2800
- content: file.content
2801
- });
2802
- }
2803
- return;
3909
+ event = {
3910
+ type: "DELETE_CANCELLED",
3911
+ session: message.session,
3912
+ files: message.files
3913
+ };
3914
+ break;
2804
3915
  case "file-synced":
2805
3916
  event = {
2806
3917
  type: "FILE_SYNCED_CONFIRMATION",
@@ -2808,18 +3919,35 @@ async function start(config) {
2808
3919
  remoteModifiedAt: message.remoteModifiedAt
2809
3920
  };
2810
3921
  break;
3922
+ case "error":
3923
+ if (message.fileName) runtime.completePendingRename(normalizeCodeFilePathWithExtension(message.fileName));
3924
+ warn(message.message);
3925
+ return;
2811
3926
  case "conflicts-resolved":
2812
3927
  event = {
2813
3928
  type: "CONFLICTS_RESOLVED",
2814
- resolution: message.resolution
3929
+ session: message.session,
3930
+ resolution: message.resolution,
3931
+ fileNames: message.fileNames
2815
3932
  };
2816
3933
  break;
2817
3934
  case "conflict-version-response":
2818
3935
  event = {
2819
- type: "CONFLICT_VERSION_RESPONSE",
3936
+ type: "RESOLVE_PENDING_CONFLICTS_WITH_VERSIONS",
2820
3937
  versions: message.versions
2821
3938
  };
2822
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
+ }
2823
3951
  default:
2824
3952
  warn(`Unhandled message type: ${message.type}`);
2825
3953
  return;
@@ -2836,25 +3964,42 @@ async function start(config) {
2836
3964
  })();
2837
3965
  });
2838
3966
  connection.on("disconnect", (client) => {
3967
+ if (isShuttingDown) {
3968
+ debug("[STATE] Ignoring disconnect during shutdown");
3969
+ return;
3970
+ }
2839
3971
  if (syncState.socket !== client) {
2840
3972
  debug("[STATE] Ignoring disconnect from stale socket");
2841
3973
  return;
2842
3974
  }
2843
- scheduleDisconnectMessage(() => {
3975
+ runtime.disconnectUi.scheduleNotice(() => {
2844
3976
  status("Disconnected, waiting to reconnect...");
2845
3977
  });
2846
3978
  (async () => {
3979
+ runtime.clearPendingRenames();
2847
3980
  await processEvent({ type: "DISCONNECT" });
2848
- userActions.cleanup();
3981
+ runtime.clearEmittedSyncStatus();
3982
+ runtime.cleanupUserActions();
2849
3983
  })();
2850
3984
  });
2851
3985
  connection.on("error", (err) => {
2852
3986
  error("Error on WebSocket connection:", err);
2853
3987
  });
2854
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
+ };
2855
4000
  const startWatcher = () => {
2856
- if (!config.filesDir || watcher) return;
2857
- watcher = initWatcher(config.filesDir);
4001
+ if (!runtime.workspace.filesDir || watcher) return;
4002
+ watcher = initWatcher(runtime.workspace.filesDir);
2858
4003
  watcher.on("change", (event) => {
2859
4004
  processEvent({
2860
4005
  type: "WATCHER_EVENT",
@@ -2866,8 +4011,7 @@ async function start(config) {
2866
4011
  console.log();
2867
4012
  status("Shutting down...");
2868
4013
  (async () => {
2869
- if (watcher) await watcher.close();
2870
- connection.close();
4014
+ await shutdown();
2871
4015
  process.exit(0);
2872
4016
  })();
2873
4017
  });
@@ -2883,6 +4027,11 @@ async function start(config) {
2883
4027
  */
2884
4028
  const { version } = createRequire(import.meta.url)("../package.json");
2885
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
+ }
2886
4035
  program.exitOverride((err) => {
2887
4036
  if (err.code === "commander.missingArgument") {
2888
4037
  console.error("Missing Project ID. Copy command via Code Link Plugin.");
@@ -2890,7 +4039,7 @@ program.exitOverride((err) => {
2890
4039
  }
2891
4040
  throw err;
2892
4041
  });
2893
- 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) => {
2894
4043
  if (!projectHash) {
2895
4044
  const detected = await getProjectHashFromCwd();
2896
4045
  if (detected) projectHash = detected;
@@ -2917,7 +4066,8 @@ program.name("framer-code-link").description("Sync Framer code components to you
2917
4066
  projectDir: null,
2918
4067
  filesDir: null,
2919
4068
  dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
2920
- allowUnsupportedNpm: options.unsupportedNpm ?? false,
4069
+ npmStrategy: options.unsupportedNpm,
4070
+ once: options.once ?? false,
2921
4071
  explicitDirectory: options.dir,
2922
4072
  explicitName: options.name
2923
4073
  };