framer-code-link 0.1.3 → 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.
@@ -4,15 +4,109 @@ import fs from "fs/promises";
4
4
  import { WebSocketServer } from "ws";
5
5
  import chokidar from "chokidar";
6
6
  import path from "path";
7
- import "url";
8
7
  import { createHash } from "crypto";
9
8
  import { setupTypeAcquisition } from "@typescript/ata";
10
9
  import ts from "typescript";
11
10
 
11
+ //#region rolldown:runtime
12
+ var __create = Object.create;
13
+ var __defProp = Object.defineProperty;
14
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
15
+ var __getOwnPropNames = Object.getOwnPropertyNames;
16
+ var __getProtoOf = Object.getPrototypeOf;
17
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
18
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
19
+ var __copyProps = (to, from, except, desc) => {
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
+ }
31
+ return to;
32
+ };
33
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
34
+ value: mod,
35
+ enumerable: true
36
+ }) : target, mod));
37
+
38
+ //#endregion
39
+ //#region ../../node_modules/picocolors/picocolors.js
40
+ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
41
+ let p = process || {}, argv = p.argv || [], env = p.env || {};
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
+ let formatter = (open, close, replace = open) => (input) => {
44
+ let string = "" + input, index = string.indexOf(close, open.length);
45
+ return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
46
+ };
47
+ let replaceClose = (string, close, replace, index) => {
48
+ let result = "", cursor = 0;
49
+ do {
50
+ result += string.substring(cursor, index) + replace;
51
+ cursor = index + close.length;
52
+ index = string.indexOf(close, cursor);
53
+ } while (~index);
54
+ return result + string.substring(cursor);
55
+ };
56
+ let createColors = (enabled = isColorSupported) => {
57
+ let f = enabled ? formatter : () => String;
58
+ return {
59
+ isColorSupported: enabled,
60
+ reset: f("\x1B[0m", "\x1B[0m"),
61
+ bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
62
+ dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
63
+ italic: f("\x1B[3m", "\x1B[23m"),
64
+ underline: f("\x1B[4m", "\x1B[24m"),
65
+ inverse: f("\x1B[7m", "\x1B[27m"),
66
+ hidden: f("\x1B[8m", "\x1B[28m"),
67
+ strikethrough: f("\x1B[9m", "\x1B[29m"),
68
+ black: f("\x1B[30m", "\x1B[39m"),
69
+ red: f("\x1B[31m", "\x1B[39m"),
70
+ green: f("\x1B[32m", "\x1B[39m"),
71
+ yellow: f("\x1B[33m", "\x1B[39m"),
72
+ blue: f("\x1B[34m", "\x1B[39m"),
73
+ magenta: f("\x1B[35m", "\x1B[39m"),
74
+ cyan: f("\x1B[36m", "\x1B[39m"),
75
+ white: f("\x1B[37m", "\x1B[39m"),
76
+ gray: f("\x1B[90m", "\x1B[39m"),
77
+ bgBlack: f("\x1B[40m", "\x1B[49m"),
78
+ bgRed: f("\x1B[41m", "\x1B[49m"),
79
+ bgGreen: f("\x1B[42m", "\x1B[49m"),
80
+ bgYellow: f("\x1B[43m", "\x1B[49m"),
81
+ bgBlue: f("\x1B[44m", "\x1B[49m"),
82
+ bgMagenta: f("\x1B[45m", "\x1B[49m"),
83
+ bgCyan: f("\x1B[46m", "\x1B[49m"),
84
+ bgWhite: f("\x1B[47m", "\x1B[49m"),
85
+ blackBright: f("\x1B[90m", "\x1B[39m"),
86
+ redBright: f("\x1B[91m", "\x1B[39m"),
87
+ greenBright: f("\x1B[92m", "\x1B[39m"),
88
+ yellowBright: f("\x1B[93m", "\x1B[39m"),
89
+ blueBright: f("\x1B[94m", "\x1B[39m"),
90
+ magentaBright: f("\x1B[95m", "\x1B[39m"),
91
+ cyanBright: f("\x1B[96m", "\x1B[39m"),
92
+ whiteBright: f("\x1B[97m", "\x1B[39m"),
93
+ bgBlackBright: f("\x1B[100m", "\x1B[49m"),
94
+ bgRedBright: f("\x1B[101m", "\x1B[49m"),
95
+ bgGreenBright: f("\x1B[102m", "\x1B[49m"),
96
+ bgYellowBright: f("\x1B[103m", "\x1B[49m"),
97
+ bgBlueBright: f("\x1B[104m", "\x1B[49m"),
98
+ bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
99
+ bgCyanBright: f("\x1B[106m", "\x1B[49m"),
100
+ bgWhiteBright: f("\x1B[107m", "\x1B[49m")
101
+ };
102
+ };
103
+ module.exports = createColors();
104
+ module.exports.createColors = createColors;
105
+ }));
106
+
107
+ //#endregion
12
108
  //#region src/utils/logging.ts
13
- /**
14
- * Logging utilities for consistent output
15
- */
109
+ var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors(), 1);
16
110
  let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
17
111
  LogLevel$1[LogLevel$1["DEBUG"] = 0] = "DEBUG";
18
112
  LogLevel$1[LogLevel$1["INFO"] = 1] = "INFO";
@@ -21,28 +115,182 @@ let LogLevel = /* @__PURE__ */ function(LogLevel$1) {
21
115
  return LogLevel$1;
22
116
  }({});
23
117
  let currentLevel = LogLevel.INFO;
118
+ let lastMessage = "";
119
+ let lastMessageCount = 0;
120
+ const CLEAR_LINE = "\x1B[2K";
121
+ const MOVE_CURSOR_UP = "\x1B[1A";
122
+ function rewriteLastLine(text) {
123
+ if (process.stdout.isTTY) process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`);
124
+ else process.stdout.write(`${text}\n`);
125
+ }
126
+ let disconnectTimer = null;
127
+ let isShowingDisconnect = false;
128
+ let hadRecentDisconnect = false;
129
+ const DISCONNECT_DELAY_MS = 4e3;
24
130
  function setLogLevel(level) {
25
131
  currentLevel = level;
26
132
  }
133
+ function dedupeMessage(message, count) {
134
+ rewriteLastLine(`${message} ${import_picocolors.default.dim(`(x${count})`)}`);
135
+ }
136
+ /**
137
+ * Flush any pending deduplicated message
138
+ */
139
+ function flushDedupe() {
140
+ if (lastMessageCount > 1) dedupeMessage(lastMessage, lastMessageCount);
141
+ lastMessage = "";
142
+ lastMessageCount = 0;
143
+ }
144
+ /**
145
+ * Log with deduplication - repeated messages within window get counted
146
+ */
147
+ function logWithDedupe(message, writer) {
148
+ if (message === lastMessage) {
149
+ lastMessageCount++;
150
+ dedupeMessage(message, lastMessageCount);
151
+ return;
152
+ }
153
+ lastMessage = message;
154
+ lastMessageCount = 1;
155
+ writer();
156
+ }
157
+ /**
158
+ * Print the startup banner - one colored line
159
+ */
160
+ function banner(version, port) {
161
+ console.log();
162
+ let message = ` ${import_picocolors.default.cyan(import_picocolors.default.bold("⚡ Code Link"))} ${import_picocolors.default.dim(`v${version}`)}`;
163
+ if (currentLevel <= LogLevel.DEBUG) message += ` ${import_picocolors.default.dim("Port")} ${import_picocolors.default.yellow(port)}`;
164
+ console.log(message);
165
+ console.log();
166
+ }
167
+ /**
168
+ * Debug-level logging - only shown with --verbose flag
169
+ */
27
170
  function debug(message, ...args) {
28
- if (currentLevel <= LogLevel.DEBUG) console.debug(`[DEBUG] ${message}`, ...args);
171
+ if (currentLevel <= LogLevel.DEBUG) console.debug(import_picocolors.default.dim(`[DEBUG] ${message}`), ...args);
29
172
  }
173
+ /**
174
+ * Info-level logging - shown by default, no prefix
175
+ */
30
176
  function info(message, ...args) {
31
- if (currentLevel <= LogLevel.INFO) console.info(`[INFO] ${message}`, ...args);
177
+ if (currentLevel <= LogLevel.INFO) {
178
+ const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message;
179
+ logWithDedupe(formatted, () => console.log(formatted));
180
+ }
32
181
  }
182
+ /**
183
+ * Warning-level logging
184
+ */
33
185
  function warn(message, ...args) {
34
- if (currentLevel <= LogLevel.WARN) console.warn(`[WARN] ${message}`, ...args);
186
+ if (currentLevel <= LogLevel.WARN) {
187
+ if (message === lastMessage) return;
188
+ flushDedupe();
189
+ lastMessage = message;
190
+ lastMessageCount = 1;
191
+ console.warn(import_picocolors.default.yellow(`⚠ ${message}`), ...args);
192
+ }
35
193
  }
194
+ /**
195
+ * Error-level logging
196
+ */
36
197
  function error(message, ...args) {
37
- if (currentLevel <= LogLevel.ERROR) console.error(`[ERROR] ${message}`, ...args);
198
+ if (currentLevel <= LogLevel.ERROR) {
199
+ flushDedupe();
200
+ console.error(import_picocolors.default.red(`✗ ${message}`), ...args);
201
+ }
38
202
  }
203
+ /**
204
+ * Success message with checkmark
205
+ */
39
206
  function success(message, ...args) {
40
- if (currentLevel <= LogLevel.INFO) console.log(`✓ ${message}`, ...args);
207
+ if (currentLevel <= LogLevel.INFO) {
208
+ flushDedupe();
209
+ console.log(import_picocolors.default.green(`✓ ${message}`), ...args);
210
+ }
211
+ }
212
+ /**
213
+ * File sync indicators
214
+ */
215
+ function fileDown(fileName) {
216
+ if (currentLevel <= LogLevel.INFO) {
217
+ const msg = ` ${import_picocolors.default.blue("↓")} ${fileName}`;
218
+ logWithDedupe(msg, () => console.log(msg));
219
+ }
220
+ }
221
+ function fileUp(fileName) {
222
+ if (currentLevel <= LogLevel.INFO) {
223
+ const msg = ` ${import_picocolors.default.green("↑")} ${fileName}`;
224
+ logWithDedupe(msg, () => console.log(msg));
225
+ }
226
+ }
227
+ function fileDelete(fileName) {
228
+ if (currentLevel <= LogLevel.INFO) {
229
+ const msg = ` ${import_picocolors.default.red("×")} ${fileName}`;
230
+ logWithDedupe(msg, () => console.log(msg));
231
+ }
232
+ }
233
+ /**
234
+ * Status message (dimmed, for "watching for changes..." etc)
235
+ */
236
+ function status(message) {
237
+ if (currentLevel <= LogLevel.INFO) {
238
+ flushDedupe();
239
+ console.log(import_picocolors.default.dim(` ${message}`));
240
+ }
241
+ }
242
+ /**
243
+ * Schedule a delayed disconnect message.
244
+ * If reconnection happens before the delay, the message is cancelled.
245
+ */
246
+ function scheduleDisconnectMessage(callback) {
247
+ if (disconnectTimer) clearTimeout(disconnectTimer);
248
+ hadRecentDisconnect = true;
249
+ isShowingDisconnect = false;
250
+ disconnectTimer = setTimeout(() => {
251
+ isShowingDisconnect = true;
252
+ callback();
253
+ disconnectTimer = null;
254
+ }, DISCONNECT_DELAY_MS);
255
+ }
256
+ /**
257
+ * Cancel pending disconnect message (called on reconnect)
258
+ */
259
+ function cancelDisconnectMessage() {
260
+ if (disconnectTimer) {
261
+ clearTimeout(disconnectTimer);
262
+ disconnectTimer = null;
263
+ }
264
+ }
265
+ /**
266
+ * Check if we showed a disconnect message (need to show reconnect)
267
+ */
268
+ function didShowDisconnect() {
269
+ return isShowingDisconnect;
270
+ }
271
+ /**
272
+ * Check if we recently saw a disconnect (even if the message was suppressed)
273
+ */
274
+ function wasRecentlyDisconnected() {
275
+ return hadRecentDisconnect;
276
+ }
277
+ /**
278
+ * Reset disconnect state after successful reconnect
279
+ */
280
+ function resetDisconnectState() {
281
+ isShowingDisconnect = false;
282
+ hadRecentDisconnect = false;
41
283
  }
42
284
 
43
285
  //#endregion
44
286
  //#region src/helpers/connection.ts
45
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
+ /**
46
294
  * Initializes a WebSocket server and returns a connection interface
47
295
  * Returns a Promise that resolves when the server is ready, or rejects on startup errors
48
296
  */
@@ -62,7 +310,7 @@ function initConnection(port) {
62
310
  error(` 1. Close any other terminal running Code Link for this project`);
63
311
  error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
64
312
  error(` Then kill the process: kill -9 <PID>`);
65
- reject(new Error(`Port ${port} is already in use`));
313
+ reject(/* @__PURE__ */ new Error(`Port ${port} is already in use`));
66
314
  } else {
67
315
  error(`Failed to start WebSocket server: ${err.message}`);
68
316
  reject(err);
@@ -73,27 +321,30 @@ function initConnection(port) {
73
321
  });
74
322
  wss.on("listening", () => {
75
323
  isReady = true;
76
- info(`WebSocket server listening on port ${port}`);
324
+ debug(`WebSocket server listening on port ${port}`);
77
325
  wss.on("connection", (ws) => {
78
326
  const connId = ++connectionId;
79
- info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`);
327
+ let handshakeReceived = false;
328
+ debug(`Client connected (conn ${connId})`);
80
329
  ws.on("message", (data) => {
81
330
  try {
82
331
  const message = JSON.parse(data.toString());
83
332
  if (message.type === "handshake") {
84
- info(`[CONN ${connId}] Received handshake`);
333
+ debug(`Received handshake (conn ${connId})`);
334
+ handshakeReceived = true;
85
335
  handlers.onHandshake?.(ws, message);
86
- } else handlers.onMessage?.(message);
336
+ } else if (handshakeReceived) handlers.onMessage?.(message);
337
+ else debug(`Ignoring ${message.type} before handshake (conn ${connId})`);
87
338
  } catch (err) {
88
- error(`[CONN ${connId}] Failed to parse message:`, err);
339
+ error(`Failed to parse message:`, err);
89
340
  }
90
341
  });
91
342
  ws.on("close", (code, reason) => {
92
- info(`[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
343
+ debug(`Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
93
344
  handlers.onDisconnect?.();
94
345
  });
95
346
  ws.on("error", (err) => {
96
- error(`[CONN ${connId}] WebSocket error:`, err);
347
+ error(`WebSocket error:`, err);
97
348
  });
98
349
  });
99
350
  resolve({
@@ -136,19 +387,74 @@ function sendMessage(socket, message) {
136
387
  return new Promise((resolve) => {
137
388
  if (socket.readyState !== READY_STATE.OPEN) {
138
389
  const stateStr = readyStateToString(socket.readyState);
139
- info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`);
390
+ debug(`Cannot send ${message.type}: socket is ${stateStr}`);
140
391
  resolve(false);
141
392
  return;
142
393
  }
143
394
  socket.send(JSON.stringify(message), (err) => {
144
395
  if (err) {
145
- error(`[WS] Send error for ${message.type}:`, err.message);
396
+ debug(`Send error for ${message.type}: ${err.message}`);
146
397
  resolve(false);
147
398
  } else resolve(true);
148
399
  });
149
400
  });
150
401
  }
151
402
 
403
+ //#endregion
404
+ //#region ../shared/dist/hash.js
405
+ /**
406
+ * Base58 alphabet (no 0/O/I/l to avoid confusion)
407
+ */
408
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
409
+ /**
410
+ * Derive a short, deterministic hash from the full Framer project hash.
411
+ * Uses a simple numeric hash encoded in base58 for compactness.
412
+ * Idempotent: if input is already the target length, returns it unchanged.
413
+ */
414
+ function shortProjectHash(fullHash, length = 8) {
415
+ if (fullHash.length === length) return fullHash;
416
+ let h1 = 0;
417
+ let h2 = 0;
418
+ for (let i = 0; i < fullHash.length; i++) {
419
+ const char = fullHash.charCodeAt(i);
420
+ h1 = Math.imul(h1 ^ char, 2246822507);
421
+ h2 = Math.imul(h2 ^ char, 3266489909);
422
+ }
423
+ h1 ^= h2 >>> 16;
424
+ h2 ^= h1 >>> 13;
425
+ let result = "";
426
+ const combined = [Math.abs(h1), Math.abs(h2)];
427
+ for (const num of combined) {
428
+ let n = num >>> 0;
429
+ while (n > 0 && result.length < length) {
430
+ result += BASE58[n % 58];
431
+ n = Math.floor(n / 58);
432
+ }
433
+ }
434
+ while (result.length < length) result += BASE58[0];
435
+ return result.slice(0, length);
436
+ }
437
+
438
+ //#endregion
439
+ //#region ../shared/dist/ports.js
440
+ /**
441
+ * Generate a deterministic port number from a project hash (full or short).
442
+ * Port range: 3847-4096 (250 possible ports)
443
+ * Must match between CLI and plugin.
444
+ *
445
+ * Internally normalizes to the short id so both full and short inputs yield the same port.
446
+ */
447
+ function getPortFromHash(projectHash) {
448
+ const shortId = shortProjectHash(projectHash);
449
+ let hash = 0;
450
+ for (let i = 0; i < shortId.length; i++) {
451
+ const char = shortId.charCodeAt(i);
452
+ hash = (hash << 5) - hash + char;
453
+ hash = hash & hash;
454
+ }
455
+ return 3847 + Math.abs(hash) % 250;
456
+ }
457
+
152
458
  //#endregion
153
459
  //#region ../shared/dist/paths.js
154
460
  /**
@@ -241,9 +547,8 @@ function sanitizeFilePath(input, capitalizeReactComponent = true) {
241
547
  const dirName = dirname(trimmed).split("/").map((part) => sanitizedDirectoryName(part)).filter((part) => Boolean(part)).join("/");
242
548
  let name = sanitizedVariableName(inputName) ?? "MyComponent";
243
549
  if ((!hasValidExtension(extension) || extension === tsxExtension) && capitalizeReactComponent) name = capitalizeFirstLetter(name);
244
- const sanitizedPath = pathJoin(dirName, name + extension);
245
550
  return {
246
- path: sanitizedPath,
551
+ path: pathJoin(dirName, name + extension),
247
552
  dirName,
248
553
  name,
249
554
  extension
@@ -252,10 +557,22 @@ function sanitizeFilePath(input, capitalizeReactComponent = true) {
252
557
  function isSupportedExtension$1(filePath) {
253
558
  return /\.(tsx?|jsx?|json)$/i.test(filePath);
254
559
  }
560
+ /**
561
+ * Pluralize a word based on count
562
+ * @example pluralize(1, "file") => "1 file"
563
+ * @example pluralize(3, "file") => "3 files"
564
+ * @example pluralize(0, "conflict") => "0 conflicts"
565
+ */
566
+ function pluralize(count, singular, plural) {
567
+ return `${count} ${count === 1 ? singular : plural ?? `${singular}s`}`;
568
+ }
255
569
 
256
570
  //#endregion
257
571
  //#region src/utils/paths.ts
258
572
  /**
573
+ * Path manipulation utilities
574
+ */
575
+ /**
259
576
  * Gets a relative path from the project directory
260
577
  */
261
578
  function getRelativePath(projectDir, absolutePath) {
@@ -288,6 +605,13 @@ function normalizePath$1(filePath) {
288
605
  //#endregion
289
606
  //#region src/helpers/watcher.ts
290
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
+ /**
291
615
  * Initializes a file watcher for the given directory
292
616
  */
293
617
  function initWatcher(filesDir) {
@@ -297,22 +621,21 @@ function initWatcher(filesDir) {
297
621
  persistent: true,
298
622
  ignoreInitial: false
299
623
  });
300
- info(`Watching directory: ${filesDir}`);
624
+ debug(`Watching directory: ${filesDir}`);
301
625
  const emitEvent = async (kind, absolutePath) => {
302
626
  if (!isSupportedExtension$1(absolutePath)) return;
303
627
  const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
304
- const sanitized = sanitizeFilePath(rawRelativePath, false);
305
- const relativePath = sanitized.path;
628
+ const relativePath = sanitizeFilePath(rawRelativePath, false).path;
306
629
  let effectiveAbsolutePath = absolutePath;
307
630
  if (relativePath !== rawRelativePath && kind === "add") {
308
631
  const newAbsolutePath = path.join(filesDir, relativePath);
309
632
  try {
310
633
  await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
311
634
  await fs.rename(absolutePath, newAbsolutePath);
312
- info(`Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`);
635
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
313
636
  effectiveAbsolutePath = newAbsolutePath;
314
637
  } catch (err) {
315
- warn(`Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`, err);
638
+ warn(`Failed to rename ${rawRelativePath}`, err);
316
639
  }
317
640
  }
318
641
  let content;
@@ -345,6 +668,13 @@ function initWatcher(filesDir) {
345
668
 
346
669
  //#endregion
347
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
+ */
348
678
  const STATE_FILE_NAME = ".framer-sync-state.json";
349
679
  const CURRENT_VERSION = 2;
350
680
  const SUPPORTED_EXTENSIONS$1 = [
@@ -414,6 +744,17 @@ async function savePersistedState(projectDir, state) {
414
744
 
415
745
  //#endregion
416
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
+ */
417
758
  const SUPPORTED_EXTENSIONS = [
418
759
  ".ts",
419
760
  ".tsx",
@@ -423,6 +764,10 @@ const SUPPORTED_EXTENSIONS = [
423
764
  ];
424
765
  const DEFAULT_EXTENSION = ".tsx";
425
766
  const DEFAULT_REMOTE_DRIFT_MS = 2e3;
767
+ /** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
768
+ function normalizeForComparison(fileName) {
769
+ return fileName.toLowerCase();
770
+ }
426
771
  /**
427
772
  * Lists all supported files in the files directory
428
773
  */
@@ -437,9 +782,7 @@ async function listFiles(filesDir) {
437
782
  continue;
438
783
  }
439
784
  if (!isSupportedExtension(entry.name)) continue;
440
- const relativePath = path.relative(filesDir, entryPath);
441
- const normalizedPath = normalizePath(relativePath);
442
- const sanitizedPath = sanitizeFilePath(normalizedPath, false).path;
785
+ const sanitizedPath = sanitizeFilePath(normalizePath(path.relative(filesDir, entryPath)), false).path;
443
786
  try {
444
787
  const [content, stats] = await Promise.all([fs.readFile(entryPath, "utf-8"), fs.stat(entryPath)]);
445
788
  files.push({
@@ -463,29 +806,51 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
463
806
  const conflicts = [];
464
807
  const writes = [];
465
808
  const localOnly = [];
809
+ const unchanged = [];
466
810
  const detect = options.detectConflicts ?? true;
467
811
  const preferRemote = options.preferRemote ?? false;
468
812
  const persistedState = options.persistedState;
813
+ const getPersistedState = (fileName) => persistedState?.get(normalizeForComparison(fileName)) ?? persistedState?.get(fileName);
469
814
  debug(`Detecting conflicts for ${remoteFiles.length} remote files`);
470
815
  const localFiles = await listFiles(filesDir);
471
- const localFileMap = new Map(localFiles.map((f) => [f.name, f]));
816
+ const localFileMap = new Map(localFiles.map((f) => [normalizeForComparison(f.name), f]));
817
+ const remoteFileMap = new Map(remoteFiles.map((f) => {
818
+ return [normalizeForComparison(resolveRemoteReference(filesDir, f.name).relativePath), f];
819
+ }));
472
820
  const processedFiles = /* @__PURE__ */ new Set();
473
821
  for (const remote of remoteFiles) {
474
822
  const normalized = resolveRemoteReference(filesDir, remote.name);
475
- const local = localFileMap.get(normalized.relativePath);
476
- processedFiles.add(normalized.relativePath);
477
- const persisted = persistedState?.get(normalized.relativePath);
823
+ const normalizedKey = normalizeForComparison(normalized.relativePath);
824
+ const local = localFileMap.get(normalizedKey);
825
+ processedFiles.add(normalizedKey);
826
+ const persisted = getPersistedState(normalized.relativePath);
478
827
  const localHash = local ? hashFileContent(local.content) : null;
479
828
  const localMatchesPersisted = !!persisted && !!local && localHash === persisted.contentHash;
480
829
  if (!local) {
481
- writes.push({
830
+ if (persisted) {
831
+ debug(`Conflict: ${normalized.relativePath} deleted locally while offline`);
832
+ conflicts.push({
833
+ fileName: normalized.relativePath,
834
+ localContent: null,
835
+ remoteContent: remote.content,
836
+ remoteModifiedAt: remote.modifiedAt,
837
+ lastSyncedAt: persisted?.timestamp
838
+ });
839
+ } else writes.push({
840
+ name: normalized.relativePath,
841
+ content: remote.content,
842
+ modifiedAt: remote.modifiedAt
843
+ });
844
+ continue;
845
+ }
846
+ if (local.content === remote.content) {
847
+ unchanged.push({
482
848
  name: normalized.relativePath,
483
849
  content: remote.content,
484
850
  modifiedAt: remote.modifiedAt
485
851
  });
486
852
  continue;
487
853
  }
488
- if (local.content === remote.content) continue;
489
854
  if (!detect || preferRemote) {
490
855
  writes.push({
491
856
  name: normalized.relativePath,
@@ -505,15 +870,39 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
505
870
  localClean
506
871
  });
507
872
  }
508
- for (const local of localFiles) if (!processedFiles.has(local.name)) localOnly.push({
509
- name: local.name,
510
- content: local.content,
511
- modifiedAt: local.modifiedAt
512
- });
873
+ for (const local of localFiles) {
874
+ const localKey = normalizeForComparison(local.name);
875
+ if (!processedFiles.has(localKey)) {
876
+ const persisted = getPersistedState(local.name);
877
+ if (persisted) {
878
+ const localClean = hashFileContent(local.content) === persisted.contentHash;
879
+ debug(`Conflict: ${local.name} deleted in Framer (localClean=${localClean})`);
880
+ conflicts.push({
881
+ fileName: local.name,
882
+ localContent: local.content,
883
+ remoteContent: null,
884
+ localModifiedAt: local.modifiedAt,
885
+ lastSyncedAt: persisted?.timestamp,
886
+ localClean
887
+ });
888
+ } else localOnly.push({
889
+ name: local.name,
890
+ content: local.content,
891
+ modifiedAt: local.modifiedAt
892
+ });
893
+ }
894
+ }
895
+ if (persistedState) for (const [fileName] of persistedState) {
896
+ const normalizedKey = normalizeForComparison(fileName);
897
+ const inLocal = localFileMap.has(normalizedKey);
898
+ const inRemote = remoteFileMap.has(normalizedKey);
899
+ if (!inLocal && !inRemote) debug(`[AUTO-RESOLVE] ${fileName}: deleted on both sides, no conflict`);
900
+ }
513
901
  return {
514
902
  conflicts,
515
903
  writes,
516
- localOnly
904
+ localOnly,
905
+ unchanged
517
906
  };
518
907
  }
519
908
  function autoResolveConflicts(conflicts, versions, options = {}) {
@@ -525,30 +914,40 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
525
914
  for (const conflict of conflicts) {
526
915
  const latestRemoteVersionMs = versionMap.get(conflict.fileName);
527
916
  const lastSyncedAt = conflict.lastSyncedAt;
528
- info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`);
917
+ const localClean = conflict.localClean === true;
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
+ }
529
929
  if (!latestRemoteVersionMs) {
530
- info(`-> No remote version data, keeping conflict`);
930
+ debug(` No remote version data, keeping conflict`);
531
931
  remainingConflicts.push(conflict);
532
932
  continue;
533
933
  }
534
934
  if (!lastSyncedAt) {
535
- info(`-> No last sync timestamp, keeping conflict`);
935
+ debug(` No last sync timestamp, keeping conflict`);
536
936
  remainingConflicts.push(conflict);
537
937
  continue;
538
938
  }
539
- info(`-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`);
540
- info(`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`);
939
+ debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`);
940
+ debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`);
541
941
  const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
542
- const localClean = conflict.localClean === true;
543
942
  if (remoteUnchanged && !localClean) {
544
- info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`);
943
+ debug(` Remote unchanged, local changed -> LOCAL`);
545
944
  autoResolvedLocal.push(conflict);
546
945
  } else if (localClean && !remoteUnchanged) {
547
- info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`);
946
+ debug(` Local unchanged, remote changed -> REMOTE`);
548
947
  autoResolvedRemote.push(conflict);
549
- } else if (remoteUnchanged && localClean) info(` -> Both unchanged. Skipping (no conflict).`);
948
+ } else if (remoteUnchanged && localClean) debug(` Both unchanged, skipping`);
550
949
  else {
551
- info(` -> Both sides changed. Real conflict.`);
950
+ debug(` Both changed, real conflict`);
552
951
  remainingConflicts.push(conflict);
553
952
  }
554
953
  }
@@ -563,7 +962,7 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
563
962
  * CRITICAL: Update hashTracker BEFORE writing to disk
564
963
  */
565
964
  async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
566
- info(`Writing ${files.length} remote files`);
965
+ debug(`Writing ${files.length} remote files`);
567
966
  for (const file of files) try {
568
967
  const normalized = resolveRemoteReference(filesDir, file.name);
569
968
  const fullPath = normalized.absolutePath;
@@ -585,12 +984,11 @@ async function deleteLocalFile(fileName, filesDir, hashTracker) {
585
984
  hashTracker.markDelete(normalized.relativePath);
586
985
  await fs.unlink(normalized.absolutePath);
587
986
  hashTracker.forget(normalized.relativePath);
588
- info(`Deleted file: ${normalized.relativePath}`);
987
+ debug(`Deleted file: ${normalized.relativePath}`);
589
988
  } catch (err) {
590
- const nodeError = err;
591
- if (nodeError?.code === "ENOENT") {
989
+ if (err?.code === "ENOENT") {
592
990
  hashTracker.forget(normalized.relativePath);
593
- info(`File already deleted: ${normalized.relativePath}`);
991
+ debug(`File already deleted: ${normalized.relativePath}`);
594
992
  return;
595
993
  }
596
994
  hashTracker.clearDelete(normalized.relativePath);
@@ -618,9 +1016,7 @@ function resolveRemoteReference(filesDir, rawName) {
618
1016
  }
619
1017
  function sanitizeRelativePath(relativePath) {
620
1018
  const trimmed = normalizePath(relativePath.trim());
621
- const hasExtension = SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext));
622
- const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`;
623
- const sanitized = sanitizeFilePath(candidate, false);
1019
+ const sanitized = sanitizeFilePath(SUPPORTED_EXTENSIONS.some((ext) => trimmed.toLowerCase().endsWith(ext)) ? trimmed : `${trimmed}${DEFAULT_EXTENSION}`, false);
624
1020
  const normalized = normalizePath(sanitized.path);
625
1021
  return {
626
1022
  relativePath: normalized,
@@ -672,12 +1068,14 @@ function extractImports(code) {
672
1068
  * Attempt to derive an npm-style package specifier from a URL import.
673
1069
  */
674
1070
  function extractPackageFromUrl(url) {
675
- const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/);
676
- return match?.[1] ?? null;
1071
+ return url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/)?.[1] ?? null;
677
1072
  }
678
1073
 
679
1074
  //#endregion
680
1075
  //#region src/helpers/installer.ts
1076
+ /**
1077
+ * Type installer helper using @typescript/ata
1078
+ */
681
1079
  const FETCH_TIMEOUT_MS = 6e4;
682
1080
  const MAX_FETCH_RETRIES = 3;
683
1081
  const REACT_TYPES_VERSION = "18.3.12";
@@ -716,11 +1114,8 @@ var Installer = class {
716
1114
  const normalized = receivedPath.replace(/^\//, "");
717
1115
  const destination = path.join(this.projectDir, normalized);
718
1116
  const pkgMatch = receivedPath.match(/\/node_modules\/(@?[^\/]+(?:\/[^\/]+)?)\//);
719
- let isFromCache = false;
720
1117
  try {
721
- const existing = await fs.readFile(destination, "utf-8");
722
- if (existing === code) {
723
- isFromCache = true;
1118
+ if (await fs.readFile(destination, "utf-8") === code) {
724
1119
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
725
1120
  seenPackages.add(pkgMatch[1]);
726
1121
  debug(`📦 Types: ${pkgMatch[1]} (from disk cache)`);
@@ -730,13 +1125,13 @@ var Installer = class {
730
1125
  } catch {}
731
1126
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
732
1127
  seenPackages.add(pkgMatch[1]);
733
- info(`📦 Types: ${pkgMatch[1]}`);
1128
+ debug(`📦 Types: ${pkgMatch[1]}`);
734
1129
  }
735
1130
  await this.writeTypeFile(receivedPath, code);
736
1131
  }
737
1132
  }
738
1133
  });
739
- info("Type installer initialized");
1134
+ debug("Type installer initialized");
740
1135
  }
741
1136
  /**
742
1137
  * Ensure the project scaffolding exists (tsconfig, declarations, etc.)
@@ -784,7 +1179,7 @@ var Installer = class {
784
1179
  const hash = imports.map((imp) => imp.name).sort().join(",");
785
1180
  if (this.processedImports.has(hash)) return;
786
1181
  this.processedImports.add(hash);
787
- info(`Processing imports for ${fileName} (${imports.length} packages)`);
1182
+ debug(`Processing imports for ${fileName} (${imports.length} packages)`);
788
1183
  try {
789
1184
  await this.ata(content);
790
1185
  } catch (err) {
@@ -819,15 +1214,9 @@ var Installer = class {
819
1214
  const pkg = npmData.versions[version];
820
1215
  if (pkg.exports && typeof pkg.exports === "object") {
821
1216
  const fixExport = (value) => {
822
- if (typeof value === "string") {
823
- const tsPath = value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
824
- return { types: tsPath };
825
- }
1217
+ if (typeof value === "string") return { types: value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts") };
826
1218
  if (value && typeof value === "object") {
827
- if ((value.import || value.require) && !value.types) {
828
- const base = value.import || value.require;
829
- value.types = base.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
830
- }
1219
+ if ((value.import || value.require) && !value.types) value.types = (value.import || value.require).replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts");
831
1220
  }
832
1221
  return value;
833
1222
  };
@@ -854,7 +1243,7 @@ var Installer = class {
854
1243
  await fs.access(tsconfigPath);
855
1244
  debug("tsconfig.json already exists");
856
1245
  } catch {
857
- const config = {
1246
+ await fs.writeFile(tsconfigPath, JSON.stringify({
858
1247
  compilerOptions: {
859
1248
  noEmit: true,
860
1249
  target: "ES2021",
@@ -877,9 +1266,8 @@ var Installer = class {
877
1266
  typeRoots: ["./node_modules/@types"]
878
1267
  },
879
1268
  include: ["files/**/*", "framer-modules.d.ts"]
880
- };
881
- await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
882
- info("Created tsconfig.json");
1269
+ }, null, 2));
1270
+ debug("Created tsconfig.json");
883
1271
  }
884
1272
  }
885
1273
  async ensurePrettierConfig() {
@@ -888,13 +1276,12 @@ var Installer = class {
888
1276
  await fs.access(prettierPath);
889
1277
  debug(".prettierrc already exists");
890
1278
  } catch {
891
- const config = {
1279
+ await fs.writeFile(prettierPath, JSON.stringify({
892
1280
  tabWidth: 4,
893
1281
  semi: false,
894
1282
  trailingComma: "es5"
895
- };
896
- await fs.writeFile(prettierPath, JSON.stringify(config, null, 2));
897
- info("Created .prettierrc");
1283
+ }, null, 2));
1284
+ debug("Created .prettierrc");
898
1285
  }
899
1286
  }
900
1287
  async ensureFramerDeclarations() {
@@ -903,15 +1290,14 @@ var Installer = class {
903
1290
  await fs.access(declarationsPath);
904
1291
  debug("framer-modules.d.ts already exists");
905
1292
  } catch {
906
- const declarations = `// Type declarations for Framer URL imports
1293
+ await fs.writeFile(declarationsPath, `// Type declarations for Framer URL imports
907
1294
  declare module "https://framer.com/m/*"
908
1295
 
909
1296
  declare module "https://framerusercontent.com/*"
910
1297
 
911
1298
  declare module "*.json"
912
- `;
913
- await fs.writeFile(declarationsPath, declarations);
914
- info("Created framer-modules.d.ts");
1299
+ `);
1300
+ debug("Created framer-modules.d.ts");
915
1301
  }
916
1302
  }
917
1303
  async ensurePackageJson() {
@@ -927,7 +1313,7 @@ declare module "*.json"
927
1313
  description: "Framer files synced with framer-code-link"
928
1314
  };
929
1315
  await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2));
930
- info("Created package.json");
1316
+ debug("Created package.json");
931
1317
  }
932
1318
  }
933
1319
  async ensureReact18Types() {
@@ -939,9 +1325,9 @@ declare module "*.json"
939
1325
  "jsx-runtime.d.ts",
940
1326
  "jsx-dev-runtime.d.ts"
941
1327
  ];
942
- if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) info("📦 React types (from cache)");
1328
+ if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) debug("📦 React types (from cache)");
943
1329
  else {
944
- info("Downloading React 18 types for Framer compatibility...");
1330
+ debug("Downloading React 18 types...");
945
1331
  await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
946
1332
  }
947
1333
  const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
@@ -950,15 +1336,14 @@ declare module "*.json"
950
1336
  "index.d.ts",
951
1337
  "client.d.ts"
952
1338
  ];
953
- if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) info("📦 React DOM types (from cache)");
1339
+ if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) debug("📦 React DOM types (from cache)");
954
1340
  else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
955
1341
  }
956
1342
  async hasTypePackage(destinationDir, version, files) {
957
1343
  try {
958
1344
  const pkgJsonPath = path.join(destinationDir, "package.json");
959
1345
  const pkgJson = await fs.readFile(pkgJsonPath, "utf-8");
960
- const parsed = JSON.parse(pkgJson);
961
- if (parsed.version !== version) return false;
1346
+ if (JSON.parse(pkgJson).version !== version) return false;
962
1347
  for (const file of files) {
963
1348
  if (file === "package.json") continue;
964
1349
  await fs.access(path.join(destinationDir, file));
@@ -1015,7 +1400,13 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1015
1400
  }
1016
1401
 
1017
1402
  //#endregion
1018
- //#region src/utils/hashing.ts
1403
+ //#region src/utils/hash-tracker.ts
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
+ */
1019
1410
  /**
1020
1411
  * Creates a hash tracker instance for echo prevention
1021
1412
  */
@@ -1029,8 +1420,7 @@ function createHashTracker() {
1029
1420
  },
1030
1421
  shouldSkip(filePath, content) {
1031
1422
  const currentHash = hashContent(content);
1032
- const storedHash = hashes.get(filePath);
1033
- return storedHash === currentHash;
1423
+ return hashes.get(filePath) === currentHash;
1034
1424
  },
1035
1425
  forget(filePath) {
1036
1426
  hashes.delete(filePath);
@@ -1057,26 +1447,11 @@ function createHashTracker() {
1057
1447
  };
1058
1448
  }
1059
1449
  /**
1060
- * Computes a hash of file content for comparison
1450
+ * Computes a SHA256 hash of file content for comparison
1061
1451
  */
1062
1452
  function hashContent(content) {
1063
1453
  return createHash("sha256").update(content).digest("hex");
1064
1454
  }
1065
- /**
1066
- * Generate a deterministic port number from a project hash
1067
- * Port range: 3847-4096 (250 possible ports)
1068
- * Uses simple hash to match client-side implementation
1069
- */
1070
- function getPortFromHash(projectHash) {
1071
- let hash = 0;
1072
- for (let i = 0; i < projectHash.length; i++) {
1073
- const char = projectHash.charCodeAt(i);
1074
- hash = (hash << 5) - hash + char;
1075
- hash = hash & hash;
1076
- }
1077
- const portOffset = Math.abs(hash) % 250;
1078
- return 3847 + portOffset;
1079
- }
1080
1455
 
1081
1456
  //#endregion
1082
1457
  //#region src/utils/file-metadata-cache.ts
@@ -1183,7 +1558,7 @@ var UserActionCoordinator = class {
1183
1558
  return await confirmationPromise;
1184
1559
  } catch (err) {
1185
1560
  if (err instanceof PluginDisconnectedError) {
1186
- info(`[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1561
+ debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1187
1562
  return false;
1188
1563
  }
1189
1564
  throw err;
@@ -1215,7 +1590,7 @@ var UserActionCoordinator = class {
1215
1590
  return new Map(results);
1216
1591
  } catch (err) {
1217
1592
  if (err instanceof PluginDisconnectedError) {
1218
- info("[USER-ACTION] Plugin disconnected while awaiting conflict decisions");
1593
+ debug("Plugin disconnected while awaiting conflict decisions");
1219
1594
  return /* @__PURE__ */ new Map();
1220
1595
  }
1221
1596
  throw err;
@@ -1230,7 +1605,7 @@ var UserActionCoordinator = class {
1230
1605
  resolve,
1231
1606
  reject
1232
1607
  });
1233
- info(`[USER-ACTION] Awaiting ${description}: ${actionId}`);
1608
+ debug(`Awaiting ${description}: ${actionId}`);
1234
1609
  });
1235
1610
  }
1236
1611
  /**
@@ -1239,12 +1614,12 @@ var UserActionCoordinator = class {
1239
1614
  handleConfirmation(actionId, value) {
1240
1615
  const pending = this.pendingActions.get(actionId);
1241
1616
  if (!pending) {
1242
- warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`);
1617
+ debug(`Unexpected confirmation for ${actionId}`);
1243
1618
  return false;
1244
1619
  }
1245
1620
  this.pendingActions.delete(actionId);
1246
1621
  pending.resolve(value);
1247
- info(`[USER-ACTION] Confirmed: ${actionId}`);
1622
+ debug(`Confirmed: ${actionId}`);
1248
1623
  return true;
1249
1624
  }
1250
1625
  /**
@@ -1253,7 +1628,7 @@ var UserActionCoordinator = class {
1253
1628
  cleanup() {
1254
1629
  for (const [actionId, pending] of this.pendingActions.entries()) {
1255
1630
  pending.reject(new PluginDisconnectedError());
1256
- warn(`[USER-ACTION] Cancelled pending action: ${actionId}`);
1631
+ debug(`Cancelled pending action: ${actionId}`);
1257
1632
  }
1258
1633
  this.pendingActions.clear();
1259
1634
  }
@@ -1301,14 +1676,13 @@ function toPackageName(name) {
1301
1676
  return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1302
1677
  }
1303
1678
  function toDirName(name) {
1304
- return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1679
+ return name.replace(/[^a-zA-Z0-9- ]/g, "-").replace(/^[-\s]+|[-\s]+$/g, "").replace(/-+/g, "-");
1305
1680
  }
1306
1681
  async function getProjectHashFromCwd() {
1307
1682
  try {
1308
1683
  const packageJsonPath = path.join(process.cwd(), "package.json");
1309
1684
  const content = await fs.readFile(packageJsonPath, "utf-8");
1310
- const pkg = JSON.parse(content);
1311
- return pkg.framerProjectId ?? null;
1685
+ return JSON.parse(content).shortProjectHash ?? null;
1312
1686
  } catch {
1313
1687
  return null;
1314
1688
  }
@@ -1325,21 +1699,22 @@ async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
1325
1699
  if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
1326
1700
  const dirName = toDirName(projectName);
1327
1701
  const pkgName = toPackageName(projectName);
1328
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
1702
+ const shortId = shortProjectHash(projectHash);
1703
+ const projectDir = path.join(cwd, dirName || shortId);
1329
1704
  await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
1330
1705
  const pkg = {
1331
- name: pkgName || projectHash,
1706
+ name: pkgName || shortId,
1332
1707
  version: "1.0.0",
1333
1708
  private: true,
1334
- framerProjectId: projectHash,
1709
+ shortProjectHash: shortId,
1710
+ framerProjectHash: projectHash,
1335
1711
  framerProjectName: projectName
1336
1712
  };
1337
1713
  await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
1338
1714
  return projectDir;
1339
1715
  }
1340
1716
  async function findExistingProjectDir(baseDir, projectHash) {
1341
- const candidate = path.join(baseDir, "package.json");
1342
- if (await matchesProject(candidate, projectHash)) return baseDir;
1717
+ if (await matchesProject(path.join(baseDir, "package.json"), projectHash)) return baseDir;
1343
1718
  const entries = await fs.readdir(baseDir, { withFileTypes: true });
1344
1719
  for (const entry of entries) {
1345
1720
  if (!entry.isDirectory()) continue;
@@ -1352,7 +1727,8 @@ async function matchesProject(packageJsonPath, projectHash) {
1352
1727
  try {
1353
1728
  const content = await fs.readFile(packageJsonPath, "utf-8");
1354
1729
  const pkg = JSON.parse(content);
1355
- return pkg.framerProjectId === projectHash;
1730
+ const inputShort = shortProjectHash(projectHash);
1731
+ return pkg.shortProjectHash === inputShort;
1356
1732
  } catch {
1357
1733
  return false;
1358
1734
  }
@@ -1360,6 +1736,11 @@ async function matchesProject(packageJsonPath, projectHash) {
1360
1736
 
1361
1737
  //#endregion
1362
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
+ */
1363
1744
  /** Log helper */
1364
1745
  function log(level, message) {
1365
1746
  return {
@@ -1369,13 +1750,23 @@ function log(level, message) {
1369
1750
  };
1370
1751
  }
1371
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
+ /**
1372
1763
  * Pure state transition function
1373
1764
  * Takes current state + event, returns new state + effects to execute
1374
1765
  */
1375
1766
  function transition(state, event) {
1376
1767
  const effects = [];
1377
1768
  switch (event.type) {
1378
- case "HANDSHAKE": {
1769
+ case "HANDSHAKE":
1379
1770
  if (state.mode !== "disconnected") {
1380
1771
  effects.push(log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`));
1381
1772
  return {
@@ -1398,9 +1789,8 @@ function transition(state, event) {
1398
1789
  },
1399
1790
  effects
1400
1791
  };
1401
- }
1402
- case "FILE_SYNCED": {
1403
- effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
1792
+ case "FILE_SYNCED":
1793
+ effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
1404
1794
  type: "UPDATE_FILE_METADATA",
1405
1795
  fileName: event.fileName,
1406
1796
  remoteModifiedAt: event.remoteModifiedAt
@@ -1409,11 +1799,10 @@ function transition(state, event) {
1409
1799
  state,
1410
1800
  effects
1411
1801
  };
1412
- }
1413
- case "DISCONNECT": {
1414
- effects.push({ type: "PERSIST_STATE" }, log("info", "Disconnected, persisting state"));
1802
+ case "DISCONNECT":
1803
+ effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
1415
1804
  if (state.mode === "conflict_resolution") {
1416
- const { pendingConflicts: _discarded,...rest } = state;
1805
+ const { pendingConflicts: _discarded, ...rest } = state;
1417
1806
  return {
1418
1807
  state: {
1419
1808
  ...rest,
@@ -1431,8 +1820,7 @@ function transition(state, event) {
1431
1820
  },
1432
1821
  effects
1433
1822
  };
1434
- }
1435
- case "REQUEST_FILES": {
1823
+ case "REQUEST_FILES":
1436
1824
  if (state.mode === "disconnected") {
1437
1825
  effects.push(log("warn", "Received REQUEST_FILES while disconnected, ignoring"));
1438
1826
  return {
@@ -1440,13 +1828,12 @@ function transition(state, event) {
1440
1828
  effects
1441
1829
  };
1442
1830
  }
1443
- effects.push(log("info", "Plugin requested file list"), { type: "LIST_LOCAL_FILES" });
1831
+ effects.push(log("debug", "Plugin requested file list"), { type: "LIST_LOCAL_FILES" });
1444
1832
  return {
1445
1833
  state,
1446
1834
  effects
1447
1835
  };
1448
- }
1449
- case "FILE_LIST": {
1836
+ case "FILE_LIST":
1450
1837
  if (state.mode !== "handshaking") {
1451
1838
  effects.push(log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`));
1452
1839
  return {
@@ -1454,7 +1841,7 @@ function transition(state, event) {
1454
1841
  effects
1455
1842
  };
1456
1843
  }
1457
- effects.push(log("info", `Received file list: ${event.files.length} files`));
1844
+ effects.push(log("debug", `Received file list: ${event.files.length} files`));
1458
1845
  effects.push({
1459
1846
  type: "DETECT_CONFLICTS",
1460
1847
  remoteFiles: event.files
@@ -1467,7 +1854,6 @@ function transition(state, event) {
1467
1854
  },
1468
1855
  effects
1469
1856
  };
1470
- }
1471
1857
  case "CONFLICTS_DETECTED": {
1472
1858
  if (state.mode !== "snapshot_processing") {
1473
1859
  effects.push(log("warn", `Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring`));
@@ -1477,12 +1863,13 @@ function transition(state, event) {
1477
1863
  };
1478
1864
  }
1479
1865
  const { conflicts, safeWrites, localOnly } = event;
1480
- if (safeWrites.length > 0) effects.push(log("info", `Applying ${safeWrites.length} safe writes`), {
1866
+ if (safeWrites.length > 0) effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), {
1481
1867
  type: "WRITE_FILES",
1482
- files: safeWrites
1868
+ files: safeWrites,
1869
+ silent: true
1483
1870
  });
1484
1871
  if (localOnly.length > 0) {
1485
- effects.push(log("info", `Uploading ${localOnly.length} local-only files`));
1872
+ effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
1486
1873
  for (const file of localOnly) effects.push({
1487
1874
  type: "SEND_MESSAGE",
1488
1875
  payload: {
@@ -1493,7 +1880,7 @@ function transition(state, event) {
1493
1880
  });
1494
1881
  }
1495
1882
  if (conflicts.length > 0) {
1496
- effects.push(log("warn", `${conflicts.length} conflicts require version verification`), log("info", "[CONFLICTS] Requesting remote version data from plugin..."), {
1883
+ effects.push(log("debug", `${pluralize(conflicts.length, "conflict")} require version check`), {
1497
1884
  type: "REQUEST_CONFLICT_VERSIONS",
1498
1885
  conflicts
1499
1886
  });
@@ -1506,7 +1893,16 @@ function transition(state, event) {
1506
1893
  effects
1507
1894
  };
1508
1895
  }
1509
- effects.push(log("info", "Initial sync complete, entering watch mode"), { type: "PERSIST_STATE" });
1896
+ const remoteTotal = state.queuedDiffs.length;
1897
+ const totalCount = remoteTotal + localOnly.length;
1898
+ const updatedCount = safeWrites.length + localOnly.length;
1899
+ const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
1900
+ effects.push({ type: "PERSIST_STATE" }, {
1901
+ type: "SYNC_COMPLETE",
1902
+ totalCount,
1903
+ updatedCount,
1904
+ unchangedCount
1905
+ });
1510
1906
  return {
1511
1907
  state: {
1512
1908
  ...state,
@@ -1535,16 +1931,17 @@ function transition(state, event) {
1535
1931
  effects
1536
1932
  };
1537
1933
  }
1538
- effects.push(log("info", `Applying remote change: ${event.file.name}`), {
1934
+ effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
1539
1935
  type: "WRITE_FILES",
1540
- files: [event.file]
1936
+ files: [event.file],
1937
+ skipEcho: true
1541
1938
  });
1542
1939
  return {
1543
1940
  state,
1544
1941
  effects
1545
1942
  };
1546
1943
  }
1547
- case "REMOTE_FILE_DELETE": {
1944
+ case "REMOTE_FILE_DELETE":
1548
1945
  if (state.mode === "disconnected") {
1549
1946
  effects.push(log("warn", `Rejected delete while disconnected: ${event.fileName}`));
1550
1947
  return {
@@ -1552,7 +1949,7 @@ function transition(state, event) {
1552
1949
  effects
1553
1950
  };
1554
1951
  }
1555
- effects.push(log("info", `Remote delete applied: ${event.fileName}`), {
1952
+ effects.push(log("debug", `Remote delete applied: ${event.fileName}`), {
1556
1953
  type: "DELETE_LOCAL_FILES",
1557
1954
  names: [event.fileName]
1558
1955
  }, { type: "PERSIST_STATE" });
@@ -1560,9 +1957,8 @@ function transition(state, event) {
1560
1957
  state,
1561
1958
  effects
1562
1959
  };
1563
- }
1564
- case "REMOTE_DELETE_CONFIRMED": {
1565
- effects.push(log("info", `Delete confirmed: ${event.fileName}`), {
1960
+ case "REMOTE_DELETE_CONFIRMED":
1961
+ effects.push(log("debug", `Delete confirmed: ${event.fileName}`), {
1566
1962
  type: "DELETE_LOCAL_FILES",
1567
1963
  names: [event.fileName]
1568
1964
  }, { type: "PERSIST_STATE" });
@@ -1570,9 +1966,8 @@ function transition(state, event) {
1570
1966
  state,
1571
1967
  effects
1572
1968
  };
1573
- }
1574
- case "REMOTE_DELETE_CANCELLED": {
1575
- effects.push(log("info", `Delete cancelled: ${event.fileName}`));
1969
+ case "REMOTE_DELETE_CANCELLED":
1970
+ effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
1576
1971
  effects.push({
1577
1972
  type: "WRITE_FILES",
1578
1973
  files: [{
@@ -1585,7 +1980,6 @@ function transition(state, event) {
1585
1980
  state,
1586
1981
  effects
1587
1982
  };
1588
- }
1589
1983
  case "CONFLICTS_RESOLVED": {
1590
1984
  if (state.mode !== "conflict_resolution") {
1591
1985
  effects.push(log("warn", `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring`));
@@ -1595,18 +1989,28 @@ function transition(state, event) {
1595
1989
  };
1596
1990
  }
1597
1991
  if (event.resolution === "remote") {
1598
- const remoteFiles = state.pendingConflicts.map((c) => ({
1599
- name: c.fileName,
1600
- content: c.remoteContent,
1601
- modifiedAt: c.remoteModifiedAt
1602
- }));
1603
- effects.push(log("info", `Applying ${remoteFiles.length} remote versions`), {
1992
+ for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
1993
+ type: "DELETE_LOCAL_FILES",
1994
+ names: [conflict.fileName]
1995
+ });
1996
+ else effects.push({
1604
1997
  type: "WRITE_FILES",
1605
- files: remoteFiles
1998
+ files: [{
1999
+ name: conflict.fileName,
2000
+ content: conflict.remoteContent,
2001
+ modifiedAt: conflict.remoteModifiedAt
2002
+ }]
1606
2003
  });
2004
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} remote versions`));
1607
2005
  } else {
1608
- effects.push(log("info", `Applying ${state.pendingConflicts.length} local versions`));
1609
- for (const conflict of state.pendingConflicts) effects.push({
2006
+ for (const conflict of state.pendingConflicts) if (conflict.localContent === null) effects.push({
2007
+ type: "SEND_MESSAGE",
2008
+ payload: {
2009
+ type: "file-delete",
2010
+ fileNames: [conflict.fileName]
2011
+ }
2012
+ });
2013
+ else effects.push({
1610
2014
  type: "SEND_MESSAGE",
1611
2015
  payload: {
1612
2016
  type: "file-change",
@@ -1614,9 +2018,15 @@ function transition(state, event) {
1614
2018
  content: conflict.localContent
1615
2019
  }
1616
2020
  });
2021
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} local versions`));
1617
2022
  }
1618
- effects.push(log("info", "All conflicts resolved, entering watch mode"), { type: "PERSIST_STATE" });
1619
- const { pendingConflicts: _discarded,...rest } = state;
2023
+ effects.push({ type: "PERSIST_STATE" }, {
2024
+ type: "SYNC_COMPLETE",
2025
+ totalCount: state.pendingConflicts.length,
2026
+ updatedCount: state.pendingConflicts.length,
2027
+ unchangedCount: 0
2028
+ });
2029
+ const { pendingConflicts: _discarded, ...rest } = state;
1620
2030
  return {
1621
2031
  state: {
1622
2032
  ...rest,
@@ -1636,7 +2046,7 @@ function transition(state, event) {
1636
2046
  }
1637
2047
  switch (kind) {
1638
2048
  case "add":
1639
- case "change": {
2049
+ case "change":
1640
2050
  if (content === void 0) {
1641
2051
  effects.push(log("warn", `Watcher event missing content: ${relativePath}`));
1642
2052
  return {
@@ -1644,22 +2054,19 @@ function transition(state, event) {
1644
2054
  effects
1645
2055
  };
1646
2056
  }
1647
- effects.push(log("info", `Local change detected: ${relativePath}`));
1648
2057
  effects.push({
1649
2058
  type: "SEND_LOCAL_CHANGE",
1650
2059
  fileName: relativePath,
1651
2060
  content
1652
2061
  });
1653
2062
  break;
1654
- }
1655
- case "delete": {
1656
- effects.push(log("info", `Local delete detected: ${relativePath}`), {
2063
+ case "delete":
2064
+ effects.push(log("debug", `Local delete detected: ${relativePath}`), {
1657
2065
  type: "REQUEST_LOCAL_DELETE_DECISION",
1658
2066
  fileName: relativePath,
1659
2067
  requireConfirmation: true
1660
2068
  });
1661
2069
  break;
1662
- }
1663
2070
  }
1664
2071
  return {
1665
2072
  state,
@@ -1676,23 +2083,37 @@ function transition(state, event) {
1676
2083
  }
1677
2084
  const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
1678
2085
  if (autoResolvedLocal.length > 0) {
1679
- effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`));
1680
- for (const conflict of autoResolvedLocal) effects.push({
2086
+ effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
2087
+ for (const conflict of autoResolvedLocal) if (conflict.localContent === null) effects.push({
2088
+ type: "SEND_MESSAGE",
2089
+ payload: {
2090
+ type: "file-delete",
2091
+ fileNames: [conflict.fileName]
2092
+ }
2093
+ });
2094
+ else effects.push({
1681
2095
  type: "SEND_LOCAL_CHANGE",
1682
2096
  fileName: conflict.fileName,
1683
2097
  content: conflict.localContent
1684
2098
  });
1685
2099
  }
1686
- if (autoResolvedRemote.length > 0) effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`), {
1687
- type: "WRITE_FILES",
1688
- files: autoResolvedRemote.map((conflict) => ({
1689
- name: conflict.fileName,
1690
- content: conflict.remoteContent,
1691
- modifiedAt: conflict.remoteModifiedAt ?? Date.now()
1692
- }))
1693
- });
2100
+ if (autoResolvedRemote.length > 0) {
2101
+ effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
2102
+ for (const conflict of autoResolvedRemote) if (conflict.remoteContent === null) effects.push({
2103
+ type: "DELETE_LOCAL_FILES",
2104
+ names: [conflict.fileName]
2105
+ });
2106
+ else effects.push({
2107
+ type: "WRITE_FILES",
2108
+ files: [{
2109
+ name: conflict.fileName,
2110
+ content: conflict.remoteContent,
2111
+ modifiedAt: conflict.remoteModifiedAt ?? Date.now()
2112
+ }]
2113
+ });
2114
+ }
1694
2115
  if (remainingConflicts.length > 0) {
1695
- effects.push(log("warn", `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`), {
2116
+ effects.push(log("warn", `${pluralize(remainingConflicts.length, "conflict")} require resolution`), {
1696
2117
  type: "REQUEST_CONFLICT_DECISIONS",
1697
2118
  conflicts: remainingConflicts
1698
2119
  });
@@ -1704,8 +2125,14 @@ function transition(state, event) {
1704
2125
  effects
1705
2126
  };
1706
2127
  }
1707
- effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), { type: "PERSIST_STATE" });
1708
- const { pendingConflicts: _discarded,...rest } = state;
2128
+ const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
2129
+ effects.push({ type: "PERSIST_STATE" }, {
2130
+ type: "SYNC_COMPLETE",
2131
+ totalCount: resolvedCount,
2132
+ updatedCount: resolvedCount,
2133
+ unchangedCount: 0
2134
+ });
2135
+ const { pendingConflicts: _discarded, ...rest } = state;
1709
2136
  return {
1710
2137
  state: {
1711
2138
  ...rest,
@@ -1715,13 +2142,12 @@ function transition(state, event) {
1715
2142
  effects
1716
2143
  };
1717
2144
  }
1718
- default: {
2145
+ default:
1719
2146
  effects.push(log("warn", `Unhandled event type in transition`));
1720
2147
  return {
1721
2148
  state,
1722
2149
  effects
1723
2150
  };
1724
- }
1725
2151
  }
1726
2152
  }
1727
2153
  /**
@@ -1731,23 +2157,21 @@ function transition(state, event) {
1731
2157
  async function executeEffect(effect, context) {
1732
2158
  const { config, hashTracker, installer, fileMetadataCache, userActions, syncState } = context;
1733
2159
  switch (effect.type) {
1734
- case "INIT_WORKSPACE": {
2160
+ case "INIT_WORKSPACE":
1735
2161
  if (!config.projectDir) {
1736
2162
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
1737
2163
  config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir);
1738
2164
  config.filesDir = `${config.projectDir}/files`;
1739
- info(`Files directory: ${config.filesDir}`);
2165
+ debug(`Files directory: ${config.filesDir}`);
1740
2166
  await fs.mkdir(config.filesDir, { recursive: true });
1741
2167
  }
1742
2168
  return [];
1743
- }
1744
- case "LOAD_PERSISTED_STATE": {
2169
+ case "LOAD_PERSISTED_STATE":
1745
2170
  if (config.projectDir) {
1746
2171
  await fileMetadataCache.initialize(config.projectDir);
1747
- info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2172
+ debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
1748
2173
  }
1749
2174
  return [];
1750
- }
1751
2175
  case "LIST_LOCAL_FILES": {
1752
2176
  if (!config.filesDir) return [];
1753
2177
  const files = await listFiles(config.filesDir);
@@ -1759,7 +2183,8 @@ async function executeEffect(effect, context) {
1759
2183
  }
1760
2184
  case "DETECT_CONFLICTS": {
1761
2185
  if (!config.filesDir) return [];
1762
- 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());
1763
2188
  return [{
1764
2189
  type: "CONFLICTS_DETECTED",
1765
2190
  conflicts,
@@ -1767,34 +2192,34 @@ async function executeEffect(effect, context) {
1767
2192
  localOnly
1768
2193
  }];
1769
2194
  }
1770
- case "SEND_MESSAGE": {
2195
+ case "SEND_MESSAGE":
1771
2196
  if (syncState.socket) {
1772
- const sent = await sendMessage(syncState.socket, effect.payload);
1773
- 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}`);
1774
2198
  } else warn(`No socket available to send: ${effect.payload.type}`);
1775
2199
  return [];
1776
- }
1777
- case "WRITE_FILES": {
2200
+ case "WRITE_FILES":
1778
2201
  if (config.filesDir) {
1779
- await writeRemoteFiles(effect.files, config.filesDir, hashTracker, installer ?? void 0);
1780
- 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) {
2207
+ if (!effect.silent) fileDown(file.name);
1781
2208
  const remoteTimestamp = file.modifiedAt ?? Date.now();
1782
2209
  fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
1783
2210
  }
1784
2211
  }
1785
2212
  return [];
1786
- }
1787
- case "DELETE_LOCAL_FILES": {
2213
+ case "DELETE_LOCAL_FILES":
1788
2214
  if (config.filesDir) for (const fileName of effect.names) {
1789
2215
  await deleteLocalFile(fileName, config.filesDir, hashTracker);
2216
+ fileDelete(fileName);
1790
2217
  fileMetadataCache.recordDelete(fileName);
1791
2218
  }
1792
2219
  return [];
1793
- }
1794
- case "REQUEST_CONFLICT_DECISIONS": {
2220
+ case "REQUEST_CONFLICT_DECISIONS":
1795
2221
  await userActions.requestConflictDecisions(syncState.socket, effect.conflicts);
1796
2222
  return [];
1797
- }
1798
2223
  case "REQUEST_CONFLICT_VERSIONS": {
1799
2224
  if (!syncState.socket) {
1800
2225
  warn("Cannot request conflict versions without active socket");
@@ -1808,21 +2233,20 @@ async function executeEffect(effect, context) {
1808
2233
  lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp
1809
2234
  };
1810
2235
  });
1811
- info(`[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`);
2236
+ debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
1812
2237
  await sendMessage(syncState.socket, {
1813
2238
  type: "conflict-version-request",
1814
2239
  conflicts: versionRequests
1815
2240
  });
1816
2241
  return [];
1817
2242
  }
1818
- case "REQUEST_DELETE_CONFIRMATION": {
2243
+ case "REQUEST_DELETE_CONFIRMATION":
1819
2244
  if (syncState.socket) await sendMessage(syncState.socket, {
1820
2245
  type: "file-delete",
1821
2246
  fileNames: [effect.fileName],
1822
2247
  requireConfirmation: effect.requireConfirmation
1823
2248
  });
1824
2249
  return [];
1825
- }
1826
2250
  case "UPDATE_FILE_METADATA": {
1827
2251
  if (!config.filesDir || !config.projectDir) return [];
1828
2252
  const currentContent = await readFileSafe(effect.fileName, config.filesDir);
@@ -1833,32 +2257,39 @@ async function executeEffect(effect, context) {
1833
2257
  return [];
1834
2258
  }
1835
2259
  case "SEND_LOCAL_CHANGE": {
2260
+ const contentHash = hashFileContent(effect.content);
2261
+ if (fileMetadataCache.get(effect.fileName)?.lastSyncedHash === contentHash) {
2262
+ debug(`Skipping local change for ${effect.fileName}: matches last synced content`);
2263
+ return [];
2264
+ }
1836
2265
  if (hashTracker.shouldSkip(effect.fileName, effect.content)) return [];
2266
+ debug(`Local change detected: ${effect.fileName}`);
1837
2267
  try {
1838
- if (syncState.socket) await sendMessage(syncState.socket, {
1839
- type: "file-change",
1840
- fileName: effect.fileName,
1841
- content: effect.content
1842
- });
2268
+ if (syncState.socket) {
2269
+ await sendMessage(syncState.socket, {
2270
+ type: "file-change",
2271
+ fileName: effect.fileName,
2272
+ content: effect.content
2273
+ });
2274
+ fileUp(effect.fileName);
2275
+ }
1843
2276
  hashTracker.remember(effect.fileName, effect.content);
1844
2277
  if (installer) installer.process(effect.fileName, effect.content);
1845
2278
  } catch (err) {
1846
- console.warn(`Failed to push change for ${effect.fileName}, will re-sync on next diff:`, err);
2279
+ warn(`Failed to push ${effect.fileName}`);
1847
2280
  }
1848
2281
  return [];
1849
2282
  }
1850
- case "REQUEST_LOCAL_DELETE_DECISION": {
1851
- const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName);
1852
- if (shouldSkip) {
2283
+ case "REQUEST_LOCAL_DELETE_DECISION":
2284
+ if (hashTracker.shouldSkipDelete(effect.fileName)) {
1853
2285
  hashTracker.clearDelete(effect.fileName);
1854
2286
  return [];
1855
2287
  }
1856
2288
  try {
1857
- const shouldDelete = await userActions.requestDeleteDecision(syncState.socket, {
2289
+ if (await userActions.requestDeleteDecision(syncState.socket, {
1858
2290
  fileName: effect.fileName,
1859
2291
  requireConfirmation: !config.dangerouslyAutoDelete
1860
- });
1861
- if (shouldDelete) {
2292
+ })) {
1862
2293
  hashTracker.forget(effect.fileName);
1863
2294
  fileMetadataCache.recordDelete(effect.fileName);
1864
2295
  if (syncState.socket) await sendMessage(syncState.socket, {
@@ -1870,25 +2301,34 @@ async function executeEffect(effect, context) {
1870
2301
  console.warn(`Failed to handle deletion for ${effect.fileName}:`, err);
1871
2302
  }
1872
2303
  return [];
1873
- }
1874
- case "PERSIST_STATE": {
2304
+ case "PERSIST_STATE":
1875
2305
  await fileMetadataCache.flush();
1876
2306
  return [];
1877
- }
1878
- case "LOG": {
1879
- const logFn = effect.level === "info" ? info : effect.level === "warn" ? warn : debug;
1880
- logFn(effect.message);
2307
+ case "SYNC_COMPLETE": {
2308
+ const wasDisconnected = wasRecentlyDisconnected();
2309
+ if (syncState.socket) await sendMessage(syncState.socket, { type: "sync-complete" });
2310
+ if (wasDisconnected) {
2311
+ if (didShowDisconnect()) {
2312
+ success(`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2313
+ status("Watching for changes...");
2314
+ }
2315
+ resetDisconnectState();
2316
+ return [];
2317
+ }
2318
+ success(`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2319
+ status("Watching for changes...");
1881
2320
  return [];
1882
2321
  }
2322
+ case "LOG":
2323
+ (effect.level === "info" ? info : effect.level === "warn" ? warn : debug)(effect.message);
2324
+ return [];
1883
2325
  }
1884
2326
  }
1885
2327
  /**
1886
2328
  * Starts the sync controller with the given configuration
1887
2329
  */
1888
2330
  async function start(config) {
1889
- info("🚀 Starting Code Link");
1890
- info(`Project: ${config.projectHash}`);
1891
- info(`Port: ${config.port} (auto-selected from project hash)`);
2331
+ status("Waiting for Plugin connection...");
1892
2332
  const hashTracker = createHashTracker();
1893
2333
  const fileMetadataCache = new FileMetadataCache();
1894
2334
  let installer = null;
@@ -1902,13 +2342,13 @@ async function start(config) {
1902
2342
  const userActions = new UserActionCoordinator();
1903
2343
  async function processEvent(event) {
1904
2344
  const socketState = syncState.socket?.readyState;
1905
- info(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
2345
+ debug(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
1906
2346
  const result = transition(syncState, event);
1907
2347
  syncState = result.state;
1908
- if (result.effects.length > 0) info(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
2348
+ if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
1909
2349
  for (const effect of result.effects) {
1910
2350
  const currentSocketState = syncState.socket?.readyState;
1911
- if (currentSocketState !== void 0 && currentSocketState !== 1) warn(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
2351
+ if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
1912
2352
  const followUpEvents = await executeEffect(effect, {
1913
2353
  config,
1914
2354
  hashTracker,
@@ -1922,10 +2362,11 @@ async function start(config) {
1922
2362
  }
1923
2363
  const connection = await initConnection(config.port);
1924
2364
  connection.on("handshake", async (client, message) => {
1925
- info("Received handshake from plugin");
1926
- info(`Project: ${message.projectName} (${message.projectId})`);
1927
- if (message.projectId !== config.projectHash) {
1928
- warn(`Project ID mismatch: expected ${config.projectHash}, got ${message.projectId}`);
2365
+ debug(`Received handshake: ${message.projectName} (${message.projectId})`);
2366
+ const expectedShort = shortProjectHash(config.projectHash);
2367
+ const receivedShort = shortProjectHash(message.projectId);
2368
+ if (receivedShort !== expectedShort) {
2369
+ warn(`Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`);
1929
2370
  client.close();
1930
2371
  return;
1931
2372
  }
@@ -1942,7 +2383,8 @@ async function start(config) {
1942
2383
  await installer.initialize();
1943
2384
  startWatcher();
1944
2385
  }
1945
- success("Handshake successful - connection established");
2386
+ cancelDisconnectMessage();
2387
+ if (!wasRecentlyDisconnected() && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
1946
2388
  });
1947
2389
  async function handleMessage(message) {
1948
2390
  if (!config.projectDir || !installer) {
@@ -1956,7 +2398,7 @@ async function start(config) {
1956
2398
  break;
1957
2399
  case "file-list": {
1958
2400
  const totalSize = message.files.reduce((sum, f) => sum + (f.content?.length ?? 0), 0);
1959
- info(`[FILE_LIST] Received ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB total)`);
2401
+ debug(`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`);
1960
2402
  event = {
1961
2403
  type: "FILE_LIST",
1962
2404
  files: message.files
@@ -1974,26 +2416,22 @@ async function start(config) {
1974
2416
  fileMeta: fileMetadataCache.get(message.fileName)
1975
2417
  };
1976
2418
  break;
1977
- case "file-delete": {
2419
+ case "file-delete":
1978
2420
  for (const fileName of message.fileNames) await processEvent({
1979
2421
  type: "REMOTE_FILE_DELETE",
1980
2422
  fileName
1981
2423
  });
1982
2424
  return;
1983
- }
1984
2425
  case "delete-confirmed": {
1985
2426
  const unmatched = [];
1986
- for (const fileName of message.fileNames) {
1987
- const handled = userActions.handleConfirmation(`delete:${fileName}`, true);
1988
- if (!handled) unmatched.push(fileName);
1989
- }
2427
+ for (const fileName of message.fileNames) if (!userActions.handleConfirmation(`delete:${fileName}`, true)) unmatched.push(fileName);
1990
2428
  for (const fileName of unmatched) await processEvent({
1991
2429
  type: "REMOTE_DELETE_CONFIRMED",
1992
2430
  fileName
1993
2431
  });
1994
2432
  return;
1995
2433
  }
1996
- case "delete-cancelled": {
2434
+ case "delete-cancelled":
1997
2435
  for (const file of message.files) {
1998
2436
  userActions.handleConfirmation(`delete:${file.fileName}`, false);
1999
2437
  await processEvent({
@@ -2003,7 +2441,6 @@ async function start(config) {
2003
2441
  });
2004
2442
  }
2005
2443
  return;
2006
- }
2007
2444
  case "file-synced":
2008
2445
  event = {
2009
2446
  type: "FILE_SYNCED",
@@ -2037,10 +2474,11 @@ async function start(config) {
2037
2474
  }
2038
2475
  });
2039
2476
  connection.on("disconnect", async () => {
2040
- warn("Plugin disconnected");
2477
+ scheduleDisconnectMessage(() => {
2478
+ status("Disconnected, waiting to reconnect...");
2479
+ });
2041
2480
  await processEvent({ type: "DISCONNECT" });
2042
2481
  userActions.cleanup();
2043
- info("Will perform full diff on reconnect");
2044
2482
  });
2045
2483
  connection.on("error", (err) => {
2046
2484
  error("Error on WebSocket connection:", err);
@@ -2056,10 +2494,9 @@ async function start(config) {
2056
2494
  });
2057
2495
  });
2058
2496
  };
2059
- info("✓ Controller initialized and ready");
2060
- info(`Waiting for plugin connection on port ${config.port}...`);
2061
2497
  process.on("SIGINT", async () => {
2062
- info("\nShutting down gracefully...");
2498
+ console.log();
2499
+ status("Shutting down...");
2063
2500
  if (watcher) await watcher.close();
2064
2501
  connection.close();
2065
2502
  process.exit(0);
@@ -2068,6 +2505,12 @@ async function start(config) {
2068
2505
 
2069
2506
  //#endregion
2070
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
+ */
2071
2514
  const program = new Command();
2072
2515
  program.exitOverride((err) => {
2073
2516
  if (err.code === "commander.missingArgument") {
@@ -2088,16 +2531,16 @@ program.name("code-link").description("Sync Framer code components to your local
2088
2531
  }
2089
2532
  const isDev = process.env.NODE_ENV === "development";
2090
2533
  if (options.logLevel) {
2091
- const levelMap = {
2534
+ const level = {
2092
2535
  debug: LogLevel.DEBUG,
2093
2536
  info: LogLevel.INFO,
2094
2537
  warn: LogLevel.WARN,
2095
2538
  error: LogLevel.ERROR
2096
- };
2097
- const level = levelMap[options.logLevel.toLowerCase()];
2539
+ }[options.logLevel.toLowerCase()];
2098
2540
  if (level !== void 0) setLogLevel(level);
2099
2541
  } else if (options.verbose || isDev) setLogLevel(LogLevel.DEBUG);
2100
2542
  const port = getPortFromHash(projectHash);
2543
+ banner("0.1.3", port);
2101
2544
  const config = {
2102
2545
  port,
2103
2546
  projectHash,
@@ -2107,7 +2550,7 @@ program.name("code-link").description("Sync Framer code components to your local
2107
2550
  explicitDir: options.dir,
2108
2551
  explicitName: options.name
2109
2552
  };
2110
- if (config.dangerouslyAutoDelete) info("⚠️ Auto-delete mode enabled - files will be deleted without confirmation");
2553
+ if (config.dangerouslyAutoDelete) warn("Auto-delete mode enabled - files will be deleted without confirmation");
2111
2554
  try {
2112
2555
  await start(config);
2113
2556
  } catch (err) {