framer-code-link 0.1.4 → 0.2.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.
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "node:module";
3
2
  import { Command } from "commander";
4
3
  import fs from "fs/promises";
5
4
  import { WebSocketServer } from "ws";
6
5
  import chokidar from "chokidar";
7
6
  import path from "path";
8
- import "url";
9
7
  import { createHash } from "crypto";
10
8
  import { setupTypeAcquisition } from "@typescript/ata";
11
9
  import ts from "typescript";
@@ -17,16 +15,18 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
17
15
  var __getOwnPropNames = Object.getOwnPropertyNames;
18
16
  var __getProtoOf = Object.getPrototypeOf;
19
17
  var __hasOwnProp = Object.prototype.hasOwnProperty;
20
- var __commonJS = (cb, mod) => function() {
21
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
22
- };
18
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
23
19
  var __copyProps = (to, from, except, desc) => {
24
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
25
- key = keys[i];
26
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
27
- get: ((k) => from[k]).bind(null, key),
28
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
29
- });
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
22
+ key = keys[i];
23
+ if (!__hasOwnProp.call(to, key) && key !== except) {
24
+ __defProp(to, key, {
25
+ get: ((k) => from[k]).bind(null, key),
26
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
27
+ });
28
+ }
29
+ }
30
30
  }
31
31
  return to;
32
32
  };
@@ -37,7 +37,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
37
 
38
38
  //#endregion
39
39
  //#region ../../node_modules/picocolors/picocolors.js
40
- var require_picocolors = __commonJS({ "../../node_modules/picocolors/picocolors.js"(exports, module) {
40
+ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
41
41
  let p = process || {}, argv = p.argv || [], env = p.env || {};
42
42
  let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
43
43
  let formatter = (open, close, replace = open) => (input) => {
@@ -102,11 +102,11 @@ var require_picocolors = __commonJS({ "../../node_modules/picocolors/picocolors.
102
102
  };
103
103
  module.exports = createColors();
104
104
  module.exports.createColors = createColors;
105
- } });
105
+ }));
106
106
 
107
107
  //#endregion
108
108
  //#region src/utils/logging.ts
109
- var import_picocolors = __toESM(require_picocolors(), 1);
109
+ var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
110
110
  let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
111
111
  LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
112
112
  LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
@@ -126,7 +126,7 @@ function rewriteLastLine(text) {
126
126
  let disconnectTimer = null;
127
127
  let isShowingDisconnect = false;
128
128
  let hadRecentDisconnect = false;
129
- const DISCONNECT_DELAY_MS = 2e3;
129
+ const DISCONNECT_DELAY_MS = 4e3;
130
130
  function setLogLevel(level) {
131
131
  currentLevel = level;
132
132
  }
@@ -184,7 +184,10 @@ function info(message, ...args) {
184
184
  */
185
185
  function warn(message, ...args) {
186
186
  if (currentLevel <= LogLevel.WARN) {
187
+ if (message === lastMessage) return;
187
188
  flushDedupe();
189
+ lastMessage = message;
190
+ lastMessageCount = 1;
188
191
  console.warn(import_picocolors.default.yellow(`⚠ ${message}`), ...args);
189
192
  }
190
193
  }
@@ -282,6 +285,12 @@ function resetDisconnectState() {
282
285
  //#endregion
283
286
  //#region src/helpers/connection.ts
284
287
  /**
288
+ * WebSocket connection helper
289
+ *
290
+ * Thin wrapper around ws.Server that normalizes handshake and surfaces
291
+ * simple callbacks. Keeps raw socket API localized.
292
+ */
293
+ /**
285
294
  * Initializes a WebSocket server and returns a connection interface
286
295
  * Returns a Promise that resolves when the server is ready, or rejects on startup errors
287
296
  */
@@ -301,7 +310,7 @@ function initConnection(port) {
301
310
  error(` 1. Close any other terminal running Code Link for this project`);
302
311
  error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
303
312
  error(` Then kill the process: kill -9 <PID>`);
304
- reject(new Error(`Port ${port} is already in use`));
313
+ reject(/* @__PURE__ */ new Error(`Port ${port} is already in use`));
305
314
  } else {
306
315
  error(`Failed to start WebSocket server: ${err.message}`);
307
316
  reject(err);
@@ -315,14 +324,17 @@ function initConnection(port) {
315
324
  debug(`WebSocket server listening on port ${port}`);
316
325
  wss.on("connection", (ws) => {
317
326
  const connId = ++connectionId;
327
+ let handshakeReceived = false;
318
328
  debug(`Client connected (conn ${connId})`);
319
329
  ws.on("message", (data) => {
320
330
  try {
321
331
  const message = JSON.parse(data.toString());
322
332
  if (message.type === "handshake") {
323
333
  debug(`Received handshake (conn ${connId})`);
334
+ handshakeReceived = true;
324
335
  handlers.onHandshake?.(ws, message);
325
- } else handlers.onMessage?.(message);
336
+ } else if (handshakeReceived) handlers.onMessage?.(message);
337
+ else debug(`Ignoring ${message.type} before handshake (conn ${connId})`);
326
338
  } catch (err) {
327
339
  error(`Failed to parse message:`, err);
328
340
  }
@@ -440,8 +452,7 @@ function getPortFromHash(projectHash) {
440
452
  hash = (hash << 5) - hash + char;
441
453
  hash = hash & hash;
442
454
  }
443
- const portOffset = Math.abs(hash) % 250;
444
- return 3847 + portOffset;
455
+ return 3847 + Math.abs(hash) % 250;
445
456
  }
446
457
 
447
458
  //#endregion
@@ -536,9 +547,8 @@ function sanitizeFilePath(input, capitalizeReactComponent = true) {
536
547
  const dirName = dirname(trimmed).split("/").map((part) => sanitizedDirectoryName(part)).filter((part) => Boolean(part)).join("/");
537
548
  let name = sanitizedVariableName(inputName) ?? "MyComponent";
538
549
  if ((!hasValidExtension(extension) || extension === tsxExtension) && capitalizeReactComponent) name = capitalizeFirstLetter(name);
539
- const sanitizedPath = pathJoin(dirName, name + extension);
540
550
  return {
541
- path: sanitizedPath,
551
+ path: pathJoin(dirName, name + extension),
542
552
  dirName,
543
553
  name,
544
554
  extension
@@ -554,13 +564,15 @@ function isSupportedExtension$1(filePath) {
554
564
  * @example pluralize(0, "conflict") => "0 conflicts"
555
565
  */
556
566
  function pluralize(count, singular, plural) {
557
- const word = count === 1 ? singular : plural ?? `${singular}s`;
558
- return `${count} ${word}`;
567
+ return `${count} ${count === 1 ? singular : plural ?? `${singular}s`}`;
559
568
  }
560
569
 
561
570
  //#endregion
562
571
  //#region src/utils/paths.ts
563
572
  /**
573
+ * Path manipulation utilities
574
+ */
575
+ /**
564
576
  * Gets a relative path from the project directory
565
577
  */
566
578
  function getRelativePath(projectDir, absolutePath) {
@@ -593,6 +605,13 @@ function normalizePath$1(filePath) {
593
605
  //#endregion
594
606
  //#region src/helpers/watcher.ts
595
607
  /**
608
+ * File watcher helper
609
+ *
610
+ * Thin wrapper around chokidar that normalizes file paths and emits
611
+ * only supported file types (ts, tsx, js, json). Controller never worries
612
+ * about addDir or platform separators.
613
+ */
614
+ /**
596
615
  * Initializes a file watcher for the given directory
597
616
  */
598
617
  function initWatcher(filesDir) {
@@ -606,8 +625,7 @@ function initWatcher(filesDir) {
606
625
  const emitEvent = async (kind, absolutePath) => {
607
626
  if (!isSupportedExtension$1(absolutePath)) return;
608
627
  const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
609
- const sanitized = sanitizeFilePath(rawRelativePath, false);
610
- const relativePath = sanitized.path;
628
+ const relativePath = sanitizeFilePath(rawRelativePath, false).path;
611
629
  let effectiveAbsolutePath = absolutePath;
612
630
  if (relativePath !== rawRelativePath && kind === "add") {
613
631
  const newAbsolutePath = path.join(filesDir, relativePath);
@@ -650,6 +668,13 @@ function initWatcher(filesDir) {
650
668
 
651
669
  //#endregion
652
670
  //#region src/utils/state-persistence.ts
671
+ /**
672
+ * State persistence helper
673
+ *
674
+ * Persists last sync timestamps along with content hashes.
675
+ * We only trust persisted timestamps if the file content hasn't changed
676
+ * (hash matches), because that means the file wasn't edited while CLI was offline.
677
+ */
653
678
  const STATE_FILE_NAME = ".framer-sync-state.json";
654
679
  const CURRENT_VERSION = 2;
655
680
  const SUPPORTED_EXTENSIONS$1 = [
@@ -719,6 +744,17 @@ async function savePersistedState(projectDir, state) {
719
744
 
720
745
  //#endregion
721
746
  //#region src/helpers/files.ts
747
+ /**
748
+ * File operations helper
749
+ *
750
+ * Single place that understands disk + conflicts. Provides:
751
+ * - listFiles: returns current filesystem state
752
+ * - detectConflicts: compares remote vs local and returns conflicts + safe writes
753
+ * - writeRemoteFiles: applies writes/deletes from remote
754
+ * - deleteLocalFile: removes a file from disk
755
+ *
756
+ * Controller decides WHEN to call these, but never computes conflicts itself.
757
+ */
722
758
  const SUPPORTED_EXTENSIONS = [
723
759
  ".ts",
724
760
  ".tsx",
@@ -746,9 +782,7 @@ async function listFiles(filesDir) {
746
782
  continue;
747
783
  }
748
784
  if (!isSupportedExtension(entry.name)) continue;
749
- const relativePath = path.relative(filesDir, entryPath);
750
- const normalizedPath = normalizePath(relativePath);
751
- const sanitizedPath = sanitizeFilePath(normalizedPath, false).path;
785
+ const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
752
786
  try {
753
787
  const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
754
788
  files.push({
@@ -772,6 +806,7 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
772
806
  const conflicts = [];
773
807
  const writes = [];
774
808
  const localOnly = [];
809
+ const unchanged = [];
775
810
  const detect = options.detectConflicts ?? true;
776
811
  const preferRemote = options.preferRemote ?? false;
777
812
  const persistedState = options.persistedState;
@@ -780,8 +815,7 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
780
815
  const localFiles = await listFiles(filesDir);
781
816
  const localFileMap = new Map(localFiles.map((f) => [normalizeForComparison(f.name), f]));
782
817
  const remoteFileMap = new Map(remoteFiles.map((f) => {
783
- const normalized = resolveRemoteReference(filesDir, f.name);
784
- return [normalizeForComparison(normalized.relativePath), f];
818
+ return [normalizeForComparison(resolveRemoteReference(filesDir, f.name).relativePath), f];
785
819
  }));
786
820
  const processedFiles = /* @__PURE__ */ new Set();
787
821
  for (const remote of remoteFiles) {
@@ -809,7 +843,14 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
809
843
  });
810
844
  continue;
811
845
  }
812
- if (local.content === remote.content) continue;
846
+ if (local.content === remote.content) {
847
+ unchanged.push({
848
+ name: normalized.relativePath,
849
+ content: remote.content,
850
+ modifiedAt: remote.modifiedAt
851
+ });
852
+ continue;
853
+ }
813
854
  if (!detect || preferRemote) {
814
855
  writes.push({
815
856
  name: normalized.relativePath,
@@ -834,13 +875,15 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
834
875
  if (!processedFiles.has(localKey)) {
835
876
  const persisted = getPersistedState(local.name);
836
877
  if (persisted) {
837
- debug(`Conflict: ${local.name} deleted in Framer while offline`);
878
+ const localClean = hashFileContent(local.content) === persisted.contentHash;
879
+ debug(`Conflict: ${local.name} deleted in Framer (localClean=${localClean})`);
838
880
  conflicts.push({
839
881
  fileName: local.name,
840
882
  localContent: local.content,
841
883
  remoteContent: null,
842
884
  localModifiedAt: local.modifiedAt,
843
- lastSyncedAt: persisted?.timestamp
885
+ lastSyncedAt: persisted?.timestamp,
886
+ localClean
844
887
  });
845
888
  } else localOnly.push({
846
889
  name: local.name,
@@ -858,7 +901,8 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
858
901
  return {
859
902
  conflicts,
860
903
  writes,
861
- localOnly
904
+ localOnly,
905
+ unchanged
862
906
  };
863
907
  }
864
908
  function autoResolveConflicts(conflicts, versions, options = {}) {
@@ -870,7 +914,18 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
870
914
  for (const conflict of conflicts) {
871
915
  const latestRemoteVersionMs = versionMap.get(conflict.fileName);
872
916
  const lastSyncedAt = conflict.lastSyncedAt;
917
+ const localClean = conflict.localClean === true;
873
918
  debug(`Auto-resolve checking ${conflict.fileName}`);
919
+ if (conflict.remoteContent === null) {
920
+ if (localClean) {
921
+ debug(` Remote deleted, local clean -> REMOTE (delete locally)`);
922
+ autoResolvedRemote.push(conflict);
923
+ } else {
924
+ debug(` Remote deleted, local modified -> conflict`);
925
+ remainingConflicts.push(conflict);
926
+ }
927
+ continue;
928
+ }
874
929
  if (!latestRemoteVersionMs) {
875
930
  debug(` No remote version data, keeping conflict`);
876
931
  remainingConflicts.push(conflict);
@@ -884,7 +939,6 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
884
939
  debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`);
885
940
  debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`);
886
941
  const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
887
- const localClean = conflict.localClean === true;
888
942
  if (remoteUnchanged && !localClean) {
889
943
  debug(` Remote unchanged, local changed -> LOCAL`);
890
944
  autoResolvedLocal.push(conflict);
@@ -932,8 +986,7 @@ async function deleteLocalFile(fileName, filesDir, hashTracker) {
932
986
  hashTracker.forget(normalized.relativePath);
933
987
  debug(`Deleted file: ${normalized.relativePath}`);
934
988
  } catch (err) {
935
- const nodeError = err;
936
- if (nodeError?.code === "ENOENT") {
989
+ if (err?.code === "ENOENT") {
937
990
  hashTracker.forget(normalized.relativePath);
938
991
  debug(`File already deleted: ${normalized.relativePath}`);
939
992
  return;
@@ -963,9 +1016,7 @@ function resolveRemoteReference(filesDir, rawName) {
963
1016
  }
964
1017
  function sanitizeRelativePath(relativePath) {
965
1018
  const trimmed = normalizePath(relativePath.trim());
966
- const hasExtension = SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext));
967
- const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`;
968
- const sanitized = sanitizeFilePath(candidate, false);
1019
+ const sanitized = sanitizeFilePath(SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext)) ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`, false);
969
1020
  const normalized = normalizePath(sanitized.path);
970
1021
  return {
971
1022
  relativePath: normalized,
@@ -1017,12 +1068,14 @@ function extractImports(code) {
1017
1068
  * Attempt to derive an npm-style package specifier from a URL import.
1018
1069
  */
1019
1070
  function extractPackageFromUrl(url) {
1020
- const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/);
1021
- return match?.[1] ?? null;
1071
+ return url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/)?.[1] ?? null;
1022
1072
  }
1023
1073
 
1024
1074
  //#endregion
1025
1075
  //#region src/helpers/installer.ts
1076
+ /**
1077
+ * Type installer helper using @typescript/ata
1078
+ */
1026
1079
  const FETCH_TIMEOUT_MS = 6e4;
1027
1080
  const MAX_FETCH_RETRIES = 3;
1028
1081
  const REACT_TYPES_VERSION = "18.3.12";
@@ -1061,11 +1114,8 @@ var Installer = class {
1061
1114
  const normalized = receivedPath.replace(/^\//, "");
1062
1115
  const destination = path.join(this.projectDir, normalized);
1063
1116
  const pkgMatch = receivedPath.match(/\/node_modules\/(@?[^\/]+(?:\/[^\/]+)?)\//);
1064
- let isFromCache = false;
1065
1117
  try {
1066
- const existing = await fs.readFile(destination, "utf-8");
1067
- if (existing === code) {
1068
- isFromCache = true;
1118
+ if (await fs.readFile(destination, "utf-8") === code) {
1069
1119
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
1070
1120
  seenPackages.add(pkgMatch[1]);
1071
1121
  debug(`📦 Types: ${pkgMatch[1]} (from disk cache)`);
@@ -1164,15 +1214,9 @@ var Installer = class {
1164
1214
  const pkg = npmData.versions[version];
1165
1215
  if (pkg.exports && typeof pkg.exports === "object") {
1166
1216
  const fixExport = (value) => {
1167
- if (typeof value === "string") {
1168
- const tsPath = value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
1169
- return { types: tsPath };
1170
- }
1217
+ if (typeof value === "string") return { types: value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts") };
1171
1218
  if (value && typeof value === "object") {
1172
- if ((value.import || value.require) && !value.types) {
1173
- const base = value.import || value.require;
1174
- value.types = base.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
1175
- }
1219
+ if ((value.import || value.require) && !value.types) value.types = (value.import || value.require).replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
1176
1220
  }
1177
1221
  return value;
1178
1222
  };
@@ -1199,7 +1243,7 @@ var Installer = class {
1199
1243
  await fs.access(tsconfigPath);
1200
1244
  debug("tsconfig.json already exists");
1201
1245
  } catch {
1202
- const config = {
1246
+ await fs.writeFile(tsconfigPath, JSON.stringify({
1203
1247
  compilerOptions: {
1204
1248
  noEmit: true,
1205
1249
  target: "ES2021",
@@ -1222,8 +1266,7 @@ var Installer = class {
1222
1266
  typeRoots: ["./node_modules/@types"]
1223
1267
  },
1224
1268
  include: ["files/**/*", "framer-modules.d.ts"]
1225
- };
1226
- await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
1269
+ }, null, 2));
1227
1270
  debug("Created tsconfig.json");
1228
1271
  }
1229
1272
  }
@@ -1233,12 +1276,11 @@ var Installer = class {
1233
1276
  await fs.access(prettierPath);
1234
1277
  debug(".prettierrc already exists");
1235
1278
  } catch {
1236
- const config = {
1279
+ await fs.writeFile(prettierPath, JSON.stringify({
1237
1280
  tabWidth: 4,
1238
1281
  semi: false,
1239
1282
  trailingComma: "es5"
1240
- };
1241
- await fs.writeFile(prettierPath, JSON.stringify(config, null, 2));
1283
+ }, null, 2));
1242
1284
  debug("Created .prettierrc");
1243
1285
  }
1244
1286
  }
@@ -1248,14 +1290,13 @@ var Installer = class {
1248
1290
  await fs.access(declarationsPath);
1249
1291
  debug("framer-modules.d.ts already exists");
1250
1292
  } catch {
1251
- const declarations = `// Type declarations for Framer URL imports
1293
+ await fs.writeFile(declarationsPath, `// Type declarations for Framer URL imports
1252
1294
  declare module "https://framer.com/m/*"
1253
1295
 
1254
1296
  declare module "https://framerusercontent.com/*"
1255
1297
 
1256
1298
  declare module "*.json"
1257
- `;
1258
- await fs.writeFile(declarationsPath, declarations);
1299
+ `);
1259
1300
  debug("Created framer-modules.d.ts");
1260
1301
  }
1261
1302
  }
@@ -1302,8 +1343,7 @@ declare module "*.json"
1302
1343
  try {
1303
1344
  const pkgJsonPath = path.join(destinationDir, "package.json");
1304
1345
  const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
1305
- const parsed = JSON.parse(pkgJson);
1306
- if (parsed.version !== version) return false;
1346
+ if (JSON.parse(pkgJson).version !== version) return false;
1307
1347
  for (const file of files) {
1308
1348
  if (file === "package.json") continue;
1309
1349
  await fs.access(path.join(destinationDir, file));
@@ -1362,6 +1402,12 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1362
1402
  //#endregion
1363
1403
  //#region src/utils/hash-tracker.ts
1364
1404
  /**
1405
+ * Hash tracking utilities for echo prevention
1406
+ *
1407
+ * The hash tracker prevents echo loops by remembering content hashes
1408
+ * and skipping watcher events for files we just wrote.
1409
+ */
1410
+ /**
1365
1411
  * Creates a hash tracker instance for echo prevention
1366
1412
  */
1367
1413
  function createHashTracker() {
@@ -1374,8 +1420,7 @@ function createHashTracker() {
1374
1420
  },
1375
1421
  shouldSkip(filePath, content) {
1376
1422
  const currentHash = hashContent(content);
1377
- const storedHash = hashes.get(filePath);
1378
- return storedHash === currentHash;
1423
+ return hashes.get(filePath) === currentHash;
1379
1424
  },
1380
1425
  forget(filePath) {
1381
1426
  hashes.delete(filePath);
@@ -1637,8 +1682,7 @@ async function getProjectHashFromCwd() {
1637
1682
  try {
1638
1683
  const packageJsonPath = path.join(process.cwd(), "package.json");
1639
1684
  const content = await fs.readFile(packageJsonPath, "utf-8");
1640
- const pkg = JSON.parse(content);
1641
- return pkg.shortProjectHash ?? null;
1685
+ return JSON.parse(content).shortProjectHash ?? null;
1642
1686
  } catch {
1643
1687
  return null;
1644
1688
  }
@@ -1670,8 +1714,7 @@ async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
1670
1714
  return projectDir;
1671
1715
  }
1672
1716
  async function findExistingProjectDir(baseDir, projectHash) {
1673
- const candidate = path.join(baseDir, "package.json");
1674
- if (await matchesProject(candidate, projectHash)) return baseDir;
1717
+ if (await matchesProject(path.join(baseDir, "package.json"), projectHash)) return baseDir;
1675
1718
  const entries = await fs.readdir(baseDir, { withFileTypes: true });
1676
1719
  for (const entry of entries) {
1677
1720
  if (!entry.isDirectory()) continue;
@@ -1693,6 +1736,11 @@ async function matchesProject(packageJsonPath, projectHash) {
1693
1736
 
1694
1737
  //#endregion
1695
1738
  //#region src/controller.ts
1739
+ /**
1740
+ * Controller
1741
+ * Single source of truth for all runtime state and orchestrates the sync lifecycle.
1742
+ * Helpers are functions that provide data - they never hold control or callbacks.
1743
+ */
1696
1744
  /** Log helper */
1697
1745
  function log(level, message) {
1698
1746
  return {
@@ -1702,13 +1750,23 @@ function log(level, message) {
1702
1750
  };
1703
1751
  }
1704
1752
  /**
1753
+ * Filter out files whose content matches the last remembered hash.
1754
+ * Used to skip inbound echoes of our own local sends.
1755
+ */
1756
+ function filterEchoedFiles(files, hashTracker) {
1757
+ return files.filter((file) => {
1758
+ if (file.content === void 0) return true;
1759
+ return !hashTracker.shouldSkip(file.name, file.content);
1760
+ });
1761
+ }
1762
+ /**
1705
1763
  * Pure state transition function
1706
1764
  * Takes current state + event, returns new state + effects to execute
1707
1765
  */
1708
1766
  function transition(state, event) {
1709
1767
  const effects = [];
1710
1768
  switch (event.type) {
1711
- case "HANDSHAKE": {
1769
+ case "HANDSHAKE":
1712
1770
  if (state.mode !== "disconnected") {
1713
1771
  effects.push(log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`));
1714
1772
  return {
@@ -1731,8 +1789,7 @@ function transition(state, event) {
1731
1789
  },
1732
1790
  effects
1733
1791
  };
1734
- }
1735
- case "FILE_SYNCED": {
1792
+ case "FILE_SYNCED":
1736
1793
  effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
1737
1794
  type: "UPDATE_FILE_METADATA",
1738
1795
  fileName: event.fileName,
@@ -1742,11 +1799,10 @@ function transition(state, event) {
1742
1799
  state,
1743
1800
  effects
1744
1801
  };
1745
- }
1746
- case "DISCONNECT": {
1802
+ case "DISCONNECT":
1747
1803
  effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
1748
1804
  if (state.mode === "conflict_resolution") {
1749
- const { pendingConflicts: _discarded,...rest } = state;
1805
+ const { pendingConflicts: _discarded, ...rest } = state;
1750
1806
  return {
1751
1807
  state: {
1752
1808
  ...rest,
@@ -1764,8 +1820,7 @@ function transition(state, event) {
1764
1820
  },
1765
1821
  effects
1766
1822
  };
1767
- }
1768
- case "REQUEST_FILES": {
1823
+ case "REQUEST_FILES":
1769
1824
  if (state.mode === "disconnected") {
1770
1825
  effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
1771
1826
  return {
@@ -1778,8 +1833,7 @@ function transition(state, event) {
1778
1833
  state,
1779
1834
  effects
1780
1835
  };
1781
- }
1782
- case "FILE_LIST": {
1836
+ case "FILE_LIST":
1783
1837
  if (state.mode !== "handshaking") {
1784
1838
  effects.push(log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`));
1785
1839
  return {
@@ -1800,7 +1854,6 @@ function transition(state, event) {
1800
1854
  },
1801
1855
  effects
1802
1856
  };
1803
- }
1804
1857
  case "CONFLICTS_DETECTED": {
1805
1858
  if (state.mode !== "snapshot_processing") {
1806
1859
  effects.push(log("warn", `Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`));
@@ -1840,7 +1893,6 @@ function transition(state, event) {
1840
1893
  effects
1841
1894
  };
1842
1895
  }
1843
- const totalSynced = safeWrites.length + localOnly.length;
1844
1896
  const remoteTotal = state.queuedDiffs.length;
1845
1897
  const totalCount = remoteTotal + localOnly.length;
1846
1898
  const updatedCount = safeWrites.length + localOnly.length;
@@ -1881,14 +1933,15 @@ function transition(state, event) {
1881
1933
  }
1882
1934
  effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
1883
1935
  type: "WRITE_FILES",
1884
- files: [event.file]
1936
+ files: [event.file],
1937
+ skipEcho: true
1885
1938
  });
1886
1939
  return {
1887
1940
  state,
1888
1941
  effects
1889
1942
  };
1890
1943
  }
1891
- case "REMOTE_FILE_DELETE": {
1944
+ case "REMOTE_FILE_DELETE":
1892
1945
  if (state.mode === "disconnected") {
1893
1946
  effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
1894
1947
  return {
@@ -1904,8 +1957,7 @@ function transition(state, event) {
1904
1957
  state,
1905
1958
  effects
1906
1959
  };
1907
- }
1908
- case "REMOTE_DELETE_CONFIRMED": {
1960
+ case "REMOTE_DELETE_CONFIRMED":
1909
1961
  effects.push(log("debug", `Delete confirmed: ${event.fileName}`), {
1910
1962
  type: "DELETE_LOCAL_FILES",
1911
1963
  names: [event.fileName]
@@ -1914,8 +1966,7 @@ function transition(state, event) {
1914
1966
  state,
1915
1967
  effects
1916
1968
  };
1917
- }
1918
- case "REMOTE_DELETE_CANCELLED": {
1969
+ case "REMOTE_DELETE_CANCELLED":
1919
1970
  effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
1920
1971
  effects.push({
1921
1972
  type: "WRITE_FILES",
@@ -1929,7 +1980,6 @@ function transition(state, event) {
1929
1980
  state,
1930
1981
  effects
1931
1982
  };
1932
- }
1933
1983
  case "CONFLICTS_RESOLVED": {
1934
1984
  if (state.mode !== "conflict_resolution") {
1935
1985
  effects.push(log("warn", `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`));
@@ -1976,7 +2026,7 @@ function transition(state, event) {
1976
2026
  updatedCount: state.pendingConflicts.length,
1977
2027
  unchangedCount: 0
1978
2028
  });
1979
- const { pendingConflicts: _discarded,...rest } = state;
2029
+ const { pendingConflicts: _discarded, ...rest } = state;
1980
2030
  return {
1981
2031
  state: {
1982
2032
  ...rest,
@@ -1996,7 +2046,7 @@ function transition(state, event) {
1996
2046
  }
1997
2047
  switch (kind) {
1998
2048
  case "add":
1999
- case "change": {
2049
+ case "change":
2000
2050
  if (content === void 0) {
2001
2051
  effects.push(log("warn", `Watcher event missing content: ${relativePath}`));
2002
2052
  return {
@@ -2010,15 +2060,13 @@ function transition(state, event) {
2010
2060
  content
2011
2061
  });
2012
2062
  break;
2013
- }
2014
- case "delete": {
2063
+ case "delete":
2015
2064
  effects.push(log("debug", `Local delete detected: ${relativePath}`), {
2016
2065
  type: "REQUEST_LOCAL_DELETE_DECISION",
2017
2066
  fileName: relativePath,
2018
2067
  requireConfirmation: true
2019
2068
  });
2020
2069
  break;
2021
- }
2022
2070
  }
2023
2071
  return {
2024
2072
  state,
@@ -2084,7 +2132,7 @@ function transition(state, event) {
2084
2132
  updatedCount: resolvedCount,
2085
2133
  unchangedCount: 0
2086
2134
  });
2087
- const { pendingConflicts: _discarded,...rest } = state;
2135
+ const { pendingConflicts: _discarded, ...rest } = state;
2088
2136
  return {
2089
2137
  state: {
2090
2138
  ...rest,
@@ -2094,13 +2142,12 @@ function transition(state, event) {
2094
2142
  effects
2095
2143
  };
2096
2144
  }
2097
- default: {
2145
+ default:
2098
2146
  effects.push(log("warn", `Unhandled event type in transition`));
2099
2147
  return {
2100
2148
  state,
2101
2149
  effects
2102
2150
  };
2103
- }
2104
2151
  }
2105
2152
  }
2106
2153
  /**
@@ -2110,7 +2157,7 @@ function transition(state, event) {
2110
2157
  async function executeEffect(effect, context) {
2111
2158
  const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context;
2112
2159
  switch (effect.type) {
2113
- case "INIT_WORKSPACE": {
2160
+ case "INIT_WORKSPACE":
2114
2161
  if (!config.projectDir) {
2115
2162
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
2116
2163
  config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir);
@@ -2119,14 +2166,12 @@ async function executeEffect(effect, context) {
2119
2166
  await fs.mkdir(config.filesDir, { recursive: true });
2120
2167
  }
2121
2168
  return [];
2122
- }
2123
- case "LOAD_PERSISTED_STATE": {
2169
+ case "LOAD_PERSISTED_STATE":
2124
2170
  if (config.projectDir) {
2125
2171
  await fileMetadataCache.initialize(config.projectDir);
2126
2172
  debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2127
2173
  }
2128
2174
  return [];
2129
- }
2130
2175
  case "LIST_LOCAL_FILES": {
2131
2176
  if (!config.filesDir) return [];
2132
2177
  const files = await listFiles(config.filesDir);
@@ -2138,7 +2183,8 @@ async function executeEffect(effect, context) {
2138
2183
  }
2139
2184
  case "DETECT_CONFLICTS": {
2140
2185
  if (!config.filesDir) return [];
2141
- const { conflicts, writes, localOnly } = await detectConflicts(effect.remoteFiles, config.filesDir, { persistedState: fileMetadataCache.getPersistedState() });
2186
+ const { conflicts, writes, localOnly, unchanged } = await detectConflicts(effect.remoteFiles, config.filesDir, { persistedState: fileMetadataCache.getPersistedState() });
2187
+ for (const file of unchanged) fileMetadataCache.recordRemoteWrite(file.name, file.content, file.modifiedAt ?? Date.now());
2142
2188
  return [{
2143
2189
  type: "CONFLICTS_DETECTED",
2144
2190
  conflicts,
@@ -2146,36 +2192,34 @@ async function executeEffect(effect, context) {
2146
2192
  localOnly
2147
2193
  }];
2148
2194
  }
2149
- case "SEND_MESSAGE": {
2195
+ case "SEND_MESSAGE":
2150
2196
  if (syncState.socket) {
2151
- const sent = await sendMessage(syncState.socket, effect.payload);
2152
- if (!sent) warn(`Failed to send message: ${effect.payload.type}`);
2197
+ if (!await sendMessage(syncState.socket, effect.payload)) warn(`Failed to send message: ${effect.payload.type}`);
2153
2198
  } else warn(`No socket available to send: ${effect.payload.type}`);
2154
2199
  return [];
2155
- }
2156
- case "WRITE_FILES": {
2200
+ case "WRITE_FILES":
2157
2201
  if (config.filesDir) {
2158
- await writeRemoteFiles(effect.files, config.filesDir, hashTracker, installer ?? void 0);
2159
- for (const file of effect.files) {
2202
+ const filesToWrite = effect.skipEcho === true ? filterEchoedFiles(effect.files, hashTracker) : effect.files;
2203
+ if (effect.skipEcho && filesToWrite.length !== effect.files.length) debug(`Skipped ${pluralize(effect.files.length - filesToWrite.length, "echoed change")}`);
2204
+ if (filesToWrite.length === 0) return [];
2205
+ await writeRemoteFiles(filesToWrite, config.filesDir, hashTracker, installer ?? void 0);
2206
+ for (const file of filesToWrite) {
2160
2207
  if (!effect.silent) fileDown(file.name);
2161
2208
  const remoteTimestamp = file.modifiedAt ?? Date.now();
2162
2209
  fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
2163
2210
  }
2164
2211
  }
2165
2212
  return [];
2166
- }
2167
- case "DELETE_LOCAL_FILES": {
2213
+ case "DELETE_LOCAL_FILES":
2168
2214
  if (config.filesDir) for (const fileName of effect.names) {
2169
2215
  await deleteLocalFile(fileName, config.filesDir, hashTracker);
2170
2216
  fileDelete(fileName);
2171
2217
  fileMetadataCache.recordDelete(fileName);
2172
2218
  }
2173
2219
  return [];
2174
- }
2175
- case "REQUEST_CONFLICT_DECISIONS": {
2220
+ case "REQUEST_CONFLICT_DECISIONS":
2176
2221
  await userActions.requestConflictDecisions(syncState.socket, effect.conflicts);
2177
2222
  return [];
2178
- }
2179
2223
  case "REQUEST_CONFLICT_VERSIONS": {
2180
2224
  if (!syncState.socket) {
2181
2225
  warn("Cannot request conflict versions without active socket");
@@ -2196,14 +2240,13 @@ async function executeEffect(effect, context) {
2196
2240
  });
2197
2241
  return [];
2198
2242
  }
2199
- case "REQUEST_DELETE_CONFIRMATION": {
2243
+ case "REQUEST_DELETE_CONFIRMATION":
2200
2244
  if (syncState.socket) await sendMessage(syncState.socket, {
2201
2245
  type: "file-delete",
2202
2246
  fileNames: [effect.fileName],
2203
2247
  requireConfirmation: effect.requireConfirmation
2204
2248
  });
2205
2249
  return [];
2206
- }
2207
2250
  case "UPDATE_FILE_METADATA": {
2208
2251
  if (!config.filesDir || !config.projectDir) return [];
2209
2252
  const currentContent = await readFileSafe(effect.fileName, config.filesDir);
@@ -2215,8 +2258,7 @@ async function executeEffect(effect, context) {
2215
2258
  }
2216
2259
  case "SEND_LOCAL_CHANGE": {
2217
2260
  const contentHash = hashFileContent(effect.content);
2218
- const metadata = fileMetadataCache.get(effect.fileName);
2219
- if (metadata?.lastSyncedHash === contentHash) {
2261
+ if (fileMetadataCache.get(effect.fileName)?.lastSyncedHash === contentHash) {
2220
2262
  debug(`Skipping local change for ${effect.fileName}: matches last synced content`);
2221
2263
  return [];
2222
2264
  }
@@ -2238,18 +2280,16 @@ async function executeEffect(effect, context) {
2238
2280
  }
2239
2281
  return [];
2240
2282
  }
2241
- case "REQUEST_LOCAL_DELETE_DECISION": {
2242
- const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName);
2243
- if (shouldSkip) {
2283
+ case "REQUEST_LOCAL_DELETE_DECISION":
2284
+ if (hashTracker.shouldSkipDelete(effect.fileName)) {
2244
2285
  hashTracker.clearDelete(effect.fileName);
2245
2286
  return [];
2246
2287
  }
2247
2288
  try {
2248
- const shouldDelete = await userActions.requestDeleteDecision(syncState.socket, {
2289
+ if (await userActions.requestDeleteDecision(syncState.socket, {
2249
2290
  fileName: effect.fileName,
2250
2291
  requireConfirmation: !config.dangerouslyAutoDelete
2251
- });
2252
- if (shouldDelete) {
2292
+ })) {
2253
2293
  hashTracker.forget(effect.fileName);
2254
2294
  fileMetadataCache.recordDelete(effect.fileName);
2255
2295
  if (syncState.socket) await sendMessage(syncState.socket, {
@@ -2261,13 +2301,12 @@ async function executeEffect(effect, context) {
2261
2301
  console.warn(`Failed to handle deletion for ${effect.fileName}:`, err);
2262
2302
  }
2263
2303
  return [];
2264
- }
2265
- case "PERSIST_STATE": {
2304
+ case "PERSIST_STATE":
2266
2305
  await fileMetadataCache.flush();
2267
2306
  return [];
2268
- }
2269
2307
  case "SYNC_COMPLETE": {
2270
2308
  const wasDisconnected = wasRecentlyDisconnected();
2309
+ if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2271
2310
  if (wasDisconnected) {
2272
2311
  if (didShowDisconnect()) {
2273
2312
  success(`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
@@ -2280,11 +2319,9 @@ async function executeEffect(effect, context) {
2280
2319
  status("Watching for changes...");
2281
2320
  return [];
2282
2321
  }
2283
- case "LOG": {
2284
- const logFn = effect.level === "info" ? info : effect.level === "warn" ? warn : debug;
2285
- logFn(effect.message);
2322
+ case "LOG":
2323
+ (effect.level === "info" ? info : effect.level === "warn" ? warn : debug)(effect.message);
2286
2324
  return [];
2287
- }
2288
2325
  }
2289
2326
  }
2290
2327
  /**
@@ -2347,8 +2384,7 @@ async function start(config) {
2347
2384
  startWatcher();
2348
2385
  }
2349
2386
  cancelDisconnectMessage();
2350
- const wasDisconnected = wasRecentlyDisconnected();
2351
- if (!wasDisconnected && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
2387
+ if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
2352
2388
  });
2353
2389
  async function handleMessage(message) {
2354
2390
  if (!config.projectDir || !installer) {
@@ -2380,26 +2416,22 @@ async function start(config) {
2380
2416
  fileMeta: fileMetadataCache.get(message.fileName)
2381
2417
  };
2382
2418
  break;
2383
- case "file-delete": {
2419
+ case "file-delete":
2384
2420
  for (const fileName of message.fileNames) await processEvent({
2385
2421
  type: "REMOTE_FILE_DELETE",
2386
2422
  fileName
2387
2423
  });
2388
2424
  return;
2389
- }
2390
2425
  case "delete-confirmed": {
2391
2426
  const unmatched = [];
2392
- for (const fileName of message.fileNames) {
2393
- const handled = userActions.handleConfirmation(`delete:${fileName}`, true);
2394
- if (!handled) unmatched.push(fileName);
2395
- }
2427
+ for (const fileName of message.fileNames) if (!userActions.handleConfirmation(`delete:${fileName}`, true)) unmatched.push(fileName);
2396
2428
  for (const fileName of unmatched) await processEvent({
2397
2429
  type: "REMOTE_DELETE_CONFIRMED",
2398
2430
  fileName
2399
2431
  });
2400
2432
  return;
2401
2433
  }
2402
- case "delete-cancelled": {
2434
+ case "delete-cancelled":
2403
2435
  for (const file of message.files) {
2404
2436
  userActions.handleConfirmation(`delete:${file.fileName}`, false);
2405
2437
  await processEvent({
@@ -2409,7 +2441,6 @@ async function start(config) {
2409
2441
  });
2410
2442
  }
2411
2443
  return;
2412
- }
2413
2444
  case "file-synced":
2414
2445
  event = {
2415
2446
  type: "FILE_SYNCED",
@@ -2474,6 +2505,12 @@ async function start(config) {
2474
2505
 
2475
2506
  //#endregion
2476
2507
  //#region src/index.ts
2508
+ /**
2509
+ * Framer Code Link CLI - Next Generation
2510
+ *
2511
+ * Entry point for the CLI tool. Parses command-line arguments and starts
2512
+ * the controller with the appropriate configuration.
2513
+ */
2477
2514
  const program = new Command();
2478
2515
  program.exitOverride((err) => {
2479
2516
  if (err.code === "commander.missingArgument") {
@@ -2494,13 +2531,12 @@ program.name("code-link").description("Sync Framer code components to your local
2494
2531
  }
2495
2532
  const isDev = process.env.NODE_ENV === "development";
2496
2533
  if (options.logLevel) {
2497
- const levelMap = {
2534
+ const level = {
2498
2535
  debug: LogLevel.DEBUG,
2499
2536
  info: LogLevel.INFO,
2500
2537
  warn: LogLevel.WARN,
2501
2538
  error: LogLevel.ERROR
2502
- };
2503
- const level = levelMap[options.logLevel.toLowerCase()];
2539
+ }[options.logLevel.toLowerCase()];
2504
2540
  if (level !== void 0) setLogLevel(level);
2505
2541
  } else if (options.verbose || isDev) setLogLevel(LogLevel.DEBUG);
2506
2542
  const port = getPortFromHash(projectHash);