framer-code-link 0.1.3 → 0.1.4

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.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import "node:module";
2
3
  import { Command } from "commander";
3
4
  import fs from "fs/promises";
4
5
  import { WebSocketServer } from "ws";
@@ -9,10 +10,103 @@ import { createHash } from "crypto";
9
10
  import { setupTypeAcquisition } from "@typescript/ata";
10
11
  import ts from "typescript";
11
12
 
13
+ //#region rolldown:runtime
14
+ var __create = Object.create;
15
+ var __defProp = Object.defineProperty;
16
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
17
+ var __getOwnPropNames = Object.getOwnPropertyNames;
18
+ var __getProtoOf = Object.getPrototypeOf;
19
+ 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
+ };
23
+ 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
+ });
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 = __commonJS({ "../../node_modules/picocolors/picocolors.js"(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 = __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,23 +115,168 @@ 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 = 2e3;
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
+ flushDedupe();
188
+ console.warn(import_picocolors.default.yellow(`⚠ ${message}`), ...args);
189
+ }
35
190
  }
191
+ /**
192
+ * Error-level logging
193
+ */
36
194
  function error(message, ...args) {
37
- if (currentLevel <= LogLevel.ERROR) console.error(`[ERROR] ${message}`, ...args);
195
+ if (currentLevel <= LogLevel.ERROR) {
196
+ flushDedupe();
197
+ console.error(import_picocolors.default.red(`✗ ${message}`), ...args);
198
+ }
38
199
  }
200
+ /**
201
+ * Success message with checkmark
202
+ */
39
203
  function success(message, ...args) {
40
- if (currentLevel <= LogLevel.INFO) console.log(`✓ ${message}`, ...args);
204
+ if (currentLevel <= LogLevel.INFO) {
205
+ flushDedupe();
206
+ console.log(import_picocolors.default.green(`✓ ${message}`), ...args);
207
+ }
208
+ }
209
+ /**
210
+ * File sync indicators
211
+ */
212
+ function fileDown(fileName) {
213
+ if (currentLevel <= LogLevel.INFO) {
214
+ const msg = ` ${import_picocolors.default.blue("↓")} ${fileName}`;
215
+ logWithDedupe(msg, () => console.log(msg));
216
+ }
217
+ }
218
+ function fileUp(fileName) {
219
+ if (currentLevel <= LogLevel.INFO) {
220
+ const msg = ` ${import_picocolors.default.green("↑")} ${fileName}`;
221
+ logWithDedupe(msg, () => console.log(msg));
222
+ }
223
+ }
224
+ function fileDelete(fileName) {
225
+ if (currentLevel <= LogLevel.INFO) {
226
+ const msg = ` ${import_picocolors.default.red("×")} ${fileName}`;
227
+ logWithDedupe(msg, () => console.log(msg));
228
+ }
229
+ }
230
+ /**
231
+ * Status message (dimmed, for "watching for changes..." etc)
232
+ */
233
+ function status(message) {
234
+ if (currentLevel <= LogLevel.INFO) {
235
+ flushDedupe();
236
+ console.log(import_picocolors.default.dim(` ${message}`));
237
+ }
238
+ }
239
+ /**
240
+ * Schedule a delayed disconnect message.
241
+ * If reconnection happens before the delay, the message is cancelled.
242
+ */
243
+ function scheduleDisconnectMessage(callback) {
244
+ if (disconnectTimer) clearTimeout(disconnectTimer);
245
+ hadRecentDisconnect = true;
246
+ isShowingDisconnect = false;
247
+ disconnectTimer = setTimeout(() => {
248
+ isShowingDisconnect = true;
249
+ callback();
250
+ disconnectTimer = null;
251
+ }, DISCONNECT_DELAY_MS);
252
+ }
253
+ /**
254
+ * Cancel pending disconnect message (called on reconnect)
255
+ */
256
+ function cancelDisconnectMessage() {
257
+ if (disconnectTimer) {
258
+ clearTimeout(disconnectTimer);
259
+ disconnectTimer = null;
260
+ }
261
+ }
262
+ /**
263
+ * Check if we showed a disconnect message (need to show reconnect)
264
+ */
265
+ function didShowDisconnect() {
266
+ return isShowingDisconnect;
267
+ }
268
+ /**
269
+ * Check if we recently saw a disconnect (even if the message was suppressed)
270
+ */
271
+ function wasRecentlyDisconnected() {
272
+ return hadRecentDisconnect;
273
+ }
274
+ /**
275
+ * Reset disconnect state after successful reconnect
276
+ */
277
+ function resetDisconnectState() {
278
+ isShowingDisconnect = false;
279
+ hadRecentDisconnect = false;
41
280
  }
42
281
 
43
282
  //#endregion
@@ -73,27 +312,27 @@ function initConnection(port) {
73
312
  });
74
313
  wss.on("listening", () => {
75
314
  isReady = true;
76
- info(`WebSocket server listening on port ${port}`);
315
+ debug(`WebSocket server listening on port ${port}`);
77
316
  wss.on("connection", (ws) => {
78
317
  const connId = ++connectionId;
79
- info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`);
318
+ debug(`Client connected (conn ${connId})`);
80
319
  ws.on("message", (data) => {
81
320
  try {
82
321
  const message = JSON.parse(data.toString());
83
322
  if (message.type === "handshake") {
84
- info(`[CONN ${connId}] Received handshake`);
323
+ debug(`Received handshake (conn ${connId})`);
85
324
  handlers.onHandshake?.(ws, message);
86
325
  } else handlers.onMessage?.(message);
87
326
  } catch (err) {
88
- error(`[CONN ${connId}] Failed to parse message:`, err);
327
+ error(`Failed to parse message:`, err);
89
328
  }
90
329
  });
91
330
  ws.on("close", (code, reason) => {
92
- info(`[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
331
+ debug(`Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
93
332
  handlers.onDisconnect?.();
94
333
  });
95
334
  ws.on("error", (err) => {
96
- error(`[CONN ${connId}] WebSocket error:`, err);
335
+ error(`WebSocket error:`, err);
97
336
  });
98
337
  });
99
338
  resolve({
@@ -136,19 +375,75 @@ function sendMessage(socket, message) {
136
375
  return new Promise((resolve) => {
137
376
  if (socket.readyState !== READY_STATE.OPEN) {
138
377
  const stateStr = readyStateToString(socket.readyState);
139
- info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`);
378
+ debug(`Cannot send ${message.type}: socket is ${stateStr}`);
140
379
  resolve(false);
141
380
  return;
142
381
  }
143
382
  socket.send(JSON.stringify(message), (err) => {
144
383
  if (err) {
145
- error(`[WS] Send error for ${message.type}:`, err.message);
384
+ debug(`Send error for ${message.type}: ${err.message}`);
146
385
  resolve(false);
147
386
  } else resolve(true);
148
387
  });
149
388
  });
150
389
  }
151
390
 
391
+ //#endregion
392
+ //#region ../shared/dist/hash.js
393
+ /**
394
+ * Base58 alphabet (no 0/O/I/l to avoid confusion)
395
+ */
396
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
397
+ /**
398
+ * Derive a short, deterministic hash from the full Framer project hash.
399
+ * Uses a simple numeric hash encoded in base58 for compactness.
400
+ * Idempotent: if input is already the target length, returns it unchanged.
401
+ */
402
+ function shortProjectHash(fullHash, length = 8) {
403
+ if (fullHash.length === length) return fullHash;
404
+ let h1 = 0;
405
+ let h2 = 0;
406
+ for (let i = 0; i < fullHash.length; i++) {
407
+ const char = fullHash.charCodeAt(i);
408
+ h1 = Math.imul(h1 ^ char, 2246822507);
409
+ h2 = Math.imul(h2 ^ char, 3266489909);
410
+ }
411
+ h1 ^= h2 >>> 16;
412
+ h2 ^= h1 >>> 13;
413
+ let result = "";
414
+ const combined = [Math.abs(h1), Math.abs(h2)];
415
+ for (const num of combined) {
416
+ let n = num >>> 0;
417
+ while (n > 0 && result.length < length) {
418
+ result += BASE58[n % 58];
419
+ n = Math.floor(n / 58);
420
+ }
421
+ }
422
+ while (result.length < length) result += BASE58[0];
423
+ return result.slice(0, length);
424
+ }
425
+
426
+ //#endregion
427
+ //#region ../shared/dist/ports.js
428
+ /**
429
+ * Generate a deterministic port number from a project hash (full or short).
430
+ * Port range: 3847-4096 (250 possible ports)
431
+ * Must match between CLI and plugin.
432
+ *
433
+ * Internally normalizes to the short id so both full and short inputs yield the same port.
434
+ */
435
+ function getPortFromHash(projectHash) {
436
+ const shortId = shortProjectHash(projectHash);
437
+ let hash = 0;
438
+ for (let i = 0; i < shortId.length; i++) {
439
+ const char = shortId.charCodeAt(i);
440
+ hash = (hash << 5) - hash + char;
441
+ hash = hash & hash;
442
+ }
443
+ const portOffset = Math.abs(hash) % 250;
444
+ return 3847 + portOffset;
445
+ }
446
+
152
447
  //#endregion
153
448
  //#region ../shared/dist/paths.js
154
449
  /**
@@ -252,6 +547,16 @@ function sanitizeFilePath(input, capitalizeReactComponent = true) {
252
547
  function isSupportedExtension$1(filePath) {
253
548
  return /\.(tsx?|jsx?|json)$/i.test(filePath);
254
549
  }
550
+ /**
551
+ * Pluralize a word based on count
552
+ * @example pluralize(1, "file") => "1 file"
553
+ * @example pluralize(3, "file") => "3 files"
554
+ * @example pluralize(0, "conflict") => "0 conflicts"
555
+ */
556
+ function pluralize(count, singular, plural) {
557
+ const word = count === 1 ? singular : plural ?? `${singular}s`;
558
+ return `${count} ${word}`;
559
+ }
255
560
 
256
561
  //#endregion
257
562
  //#region src/utils/paths.ts
@@ -297,7 +602,7 @@ function initWatcher(filesDir) {
297
602
  persistent: true,
298
603
  ignoreInitial: false
299
604
  });
300
- info(`Watching directory: ${filesDir}`);
605
+ debug(`Watching directory: ${filesDir}`);
301
606
  const emitEvent = async (kind, absolutePath) => {
302
607
  if (!isSupportedExtension$1(absolutePath)) return;
303
608
  const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
@@ -309,10 +614,10 @@ function initWatcher(filesDir) {
309
614
  try {
310
615
  await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
311
616
  await fs.rename(absolutePath, newAbsolutePath);
312
- info(`Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`);
617
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
313
618
  effectiveAbsolutePath = newAbsolutePath;
314
619
  } catch (err) {
315
- warn(`Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`, err);
620
+ warn(`Failed to rename ${rawRelativePath}`, err);
316
621
  }
317
622
  }
318
623
  let content;
@@ -423,6 +728,10 @@ const SUPPORTED_EXTENSIONS = [
423
728
  ];
424
729
  const DEFAULT_EXTENSION = ".tsx";
425
730
  const DEFAULT_REMOTE_DRIFT_MS = 2e3;
731
+ /** Normalize file name for case-insensitive comparison (macOS/Windows compat) */
732
+ function normalizeForComparison(fileName) {
733
+ return fileName.toLowerCase();
734
+ }
426
735
  /**
427
736
  * Lists all supported files in the files directory
428
737
  */
@@ -466,19 +775,34 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
466
775
  const detect = options.detectConflicts ?? true;
467
776
  const preferRemote = options.preferRemote ?? false;
468
777
  const persistedState = options.persistedState;
778
+ const getPersistedState = (fileName) => persistedState?.get(normalizeForComparison(fileName)) ?? persistedState?.get(fileName);
469
779
  debug(`Detecting conflicts for ${remoteFiles.length} remote files`);
470
780
  const localFiles = await listFiles(filesDir);
471
- const localFileMap = new Map(localFiles.map((f) => [f.name, f]));
781
+ const localFileMap = new Map(localFiles.map((f) => [normalizeForComparison(f.name), f]));
782
+ const remoteFileMap = new Map(remoteFiles.map((f) => {
783
+ const normalized = resolveRemoteReference(filesDir, f.name);
784
+ return [normalizeForComparison(normalized.relativePath), f];
785
+ }));
472
786
  const processedFiles = /* @__PURE__ */ new Set();
473
787
  for (const remote of remoteFiles) {
474
788
  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);
789
+ const normalizedKey = normalizeForComparison(normalized.relativePath);
790
+ const local = localFileMap.get(normalizedKey);
791
+ processedFiles.add(normalizedKey);
792
+ const persisted = getPersistedState(normalized.relativePath);
478
793
  const localHash = local ? hashFileContent(local.content) : null;
479
794
  const localMatchesPersisted = !!persisted && !!local && localHash === persisted.contentHash;
480
795
  if (!local) {
481
- writes.push({
796
+ if (persisted) {
797
+ debug(`Conflict: ${normalized.relativePath} deleted locally while offline`);
798
+ conflicts.push({
799
+ fileName: normalized.relativePath,
800
+ localContent: null,
801
+ remoteContent: remote.content,
802
+ remoteModifiedAt: remote.modifiedAt,
803
+ lastSyncedAt: persisted?.timestamp
804
+ });
805
+ } else writes.push({
482
806
  name: normalized.relativePath,
483
807
  content: remote.content,
484
808
  modifiedAt: remote.modifiedAt
@@ -505,11 +829,32 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
505
829
  localClean
506
830
  });
507
831
  }
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
- });
832
+ for (const local of localFiles) {
833
+ const localKey = normalizeForComparison(local.name);
834
+ if (!processedFiles.has(localKey)) {
835
+ const persisted = getPersistedState(local.name);
836
+ if (persisted) {
837
+ debug(`Conflict: ${local.name} deleted in Framer while offline`);
838
+ conflicts.push({
839
+ fileName: local.name,
840
+ localContent: local.content,
841
+ remoteContent: null,
842
+ localModifiedAt: local.modifiedAt,
843
+ lastSyncedAt: persisted?.timestamp
844
+ });
845
+ } else localOnly.push({
846
+ name: local.name,
847
+ content: local.content,
848
+ modifiedAt: local.modifiedAt
849
+ });
850
+ }
851
+ }
852
+ if (persistedState) for (const [fileName] of persistedState) {
853
+ const normalizedKey = normalizeForComparison(fileName);
854
+ const inLocal = localFileMap.has(normalizedKey);
855
+ const inRemote = remoteFileMap.has(normalizedKey);
856
+ if (!inLocal && !inRemote) debug(`[AUTO-RESOLVE] ${fileName}: deleted on both sides, no conflict`);
857
+ }
513
858
  return {
514
859
  conflicts,
515
860
  writes,
@@ -525,30 +870,30 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
525
870
  for (const conflict of conflicts) {
526
871
  const latestRemoteVersionMs = versionMap.get(conflict.fileName);
527
872
  const lastSyncedAt = conflict.lastSyncedAt;
528
- info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`);
873
+ debug(`Auto-resolve checking ${conflict.fileName}`);
529
874
  if (!latestRemoteVersionMs) {
530
- info(`-> No remote version data, keeping conflict`);
875
+ debug(` No remote version data, keeping conflict`);
531
876
  remainingConflicts.push(conflict);
532
877
  continue;
533
878
  }
534
879
  if (!lastSyncedAt) {
535
- info(`-> No last sync timestamp, keeping conflict`);
880
+ debug(` No last sync timestamp, keeping conflict`);
536
881
  remainingConflicts.push(conflict);
537
882
  continue;
538
883
  }
539
- info(`-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`);
540
- info(`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`);
884
+ debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`);
885
+ debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`);
541
886
  const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
542
887
  const localClean = conflict.localClean === true;
543
888
  if (remoteUnchanged && !localClean) {
544
- info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`);
889
+ debug(` Remote unchanged, local changed -> LOCAL`);
545
890
  autoResolvedLocal.push(conflict);
546
891
  } else if (localClean && !remoteUnchanged) {
547
- info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`);
892
+ debug(` Local unchanged, remote changed -> REMOTE`);
548
893
  autoResolvedRemote.push(conflict);
549
- } else if (remoteUnchanged && localClean) info(` -> Both unchanged. Skipping (no conflict).`);
894
+ } else if (remoteUnchanged && localClean) debug(` Both unchanged, skipping`);
550
895
  else {
551
- info(` -> Both sides changed. Real conflict.`);
896
+ debug(` Both changed, real conflict`);
552
897
  remainingConflicts.push(conflict);
553
898
  }
554
899
  }
@@ -563,7 +908,7 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
563
908
  * CRITICAL: Update hashTracker BEFORE writing to disk
564
909
  */
565
910
  async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
566
- info(`Writing ${files.length} remote files`);
911
+ debug(`Writing ${files.length} remote files`);
567
912
  for (const file of files) try {
568
913
  const normalized = resolveRemoteReference(filesDir, file.name);
569
914
  const fullPath = normalized.absolutePath;
@@ -585,12 +930,12 @@ async function deleteLocalFile(fileName, filesDir, hashTracker) {
585
930
  hashTracker.markDelete(normalized.relativePath);
586
931
  await fs.unlink(normalized.absolutePath);
587
932
  hashTracker.forget(normalized.relativePath);
588
- info(`Deleted file: ${normalized.relativePath}`);
933
+ debug(`Deleted file: ${normalized.relativePath}`);
589
934
  } catch (err) {
590
935
  const nodeError = err;
591
936
  if (nodeError?.code === "ENOENT") {
592
937
  hashTracker.forget(normalized.relativePath);
593
- info(`File already deleted: ${normalized.relativePath}`);
938
+ debug(`File already deleted: ${normalized.relativePath}`);
594
939
  return;
595
940
  }
596
941
  hashTracker.clearDelete(normalized.relativePath);
@@ -730,13 +1075,13 @@ var Installer = class {
730
1075
  } catch {}
731
1076
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
732
1077
  seenPackages.add(pkgMatch[1]);
733
- info(`📦 Types: ${pkgMatch[1]}`);
1078
+ debug(`📦 Types: ${pkgMatch[1]}`);
734
1079
  }
735
1080
  await this.writeTypeFile(receivedPath, code);
736
1081
  }
737
1082
  }
738
1083
  });
739
- info("Type installer initialized");
1084
+ debug("Type installer initialized");
740
1085
  }
741
1086
  /**
742
1087
  * Ensure the project scaffolding exists (tsconfig, declarations, etc.)
@@ -784,7 +1129,7 @@ var Installer = class {
784
1129
  const hash = imports.map((imp) => imp.name).sort().join(",");
785
1130
  if (this.processedImports.has(hash)) return;
786
1131
  this.processedImports.add(hash);
787
- info(`Processing imports for ${fileName} (${imports.length} packages)`);
1132
+ debug(`Processing imports for ${fileName} (${imports.length} packages)`);
788
1133
  try {
789
1134
  await this.ata(content);
790
1135
  } catch (err) {
@@ -879,7 +1224,7 @@ var Installer = class {
879
1224
  include: ["files/**/*", "framer-modules.d.ts"]
880
1225
  };
881
1226
  await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
882
- info("Created tsconfig.json");
1227
+ debug("Created tsconfig.json");
883
1228
  }
884
1229
  }
885
1230
  async ensurePrettierConfig() {
@@ -894,7 +1239,7 @@ var Installer = class {
894
1239
  trailingComma: "es5"
895
1240
  };
896
1241
  await fs.writeFile(prettierPath, JSON.stringify(config, null, 2));
897
- info("Created .prettierrc");
1242
+ debug("Created .prettierrc");
898
1243
  }
899
1244
  }
900
1245
  async ensureFramerDeclarations() {
@@ -911,7 +1256,7 @@ declare module "https://framerusercontent.com/*"
911
1256
  declare module "*.json"
912
1257
  `;
913
1258
  await fs.writeFile(declarationsPath, declarations);
914
- info("Created framer-modules.d.ts");
1259
+ debug("Created framer-modules.d.ts");
915
1260
  }
916
1261
  }
917
1262
  async ensurePackageJson() {
@@ -927,7 +1272,7 @@ declare module "*.json"
927
1272
  description: "Framer files synced with framer-code-link"
928
1273
  };
929
1274
  await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2));
930
- info("Created package.json");
1275
+ debug("Created package.json");
931
1276
  }
932
1277
  }
933
1278
  async ensureReact18Types() {
@@ -939,9 +1284,9 @@ declare module "*.json"
939
1284
  "jsx-runtime.d.ts",
940
1285
  "jsx-dev-runtime.d.ts"
941
1286
  ];
942
- if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) info("📦 React types (from cache)");
1287
+ if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) debug("📦 React types (from cache)");
943
1288
  else {
944
- info("Downloading React 18 types for Framer compatibility...");
1289
+ debug("Downloading React 18 types...");
945
1290
  await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
946
1291
  }
947
1292
  const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
@@ -950,7 +1295,7 @@ declare module "*.json"
950
1295
  "index.d.ts",
951
1296
  "client.d.ts"
952
1297
  ];
953
- if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) info("📦 React DOM types (from cache)");
1298
+ if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) debug("📦 React DOM types (from cache)");
954
1299
  else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
955
1300
  }
956
1301
  async hasTypePackage(destinationDir, version, files) {
@@ -1015,7 +1360,7 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
1015
1360
  }
1016
1361
 
1017
1362
  //#endregion
1018
- //#region src/utils/hashing.ts
1363
+ //#region src/utils/hash-tracker.ts
1019
1364
  /**
1020
1365
  * Creates a hash tracker instance for echo prevention
1021
1366
  */
@@ -1057,26 +1402,11 @@ function createHashTracker() {
1057
1402
  };
1058
1403
  }
1059
1404
  /**
1060
- * Computes a hash of file content for comparison
1405
+ * Computes a SHA256 hash of file content for comparison
1061
1406
  */
1062
1407
  function hashContent(content) {
1063
1408
  return createHash("sha256").update(content).digest("hex");
1064
1409
  }
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
1410
 
1081
1411
  //#endregion
1082
1412
  //#region src/utils/file-metadata-cache.ts
@@ -1183,7 +1513,7 @@ var UserActionCoordinator = class {
1183
1513
  return await confirmationPromise;
1184
1514
  } catch (err) {
1185
1515
  if (err instanceof PluginDisconnectedError) {
1186
- info(`[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1516
+ debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1187
1517
  return false;
1188
1518
  }
1189
1519
  throw err;
@@ -1215,7 +1545,7 @@ var UserActionCoordinator = class {
1215
1545
  return new Map(results);
1216
1546
  } catch (err) {
1217
1547
  if (err instanceof PluginDisconnectedError) {
1218
- info("[USER-ACTION] Plugin disconnected while awaiting conflict decisions");
1548
+ debug("Plugin disconnected while awaiting conflict decisions");
1219
1549
  return /* @__PURE__ */ new Map();
1220
1550
  }
1221
1551
  throw err;
@@ -1230,7 +1560,7 @@ var UserActionCoordinator = class {
1230
1560
  resolve,
1231
1561
  reject
1232
1562
  });
1233
- info(`[USER-ACTION] Awaiting ${description}: ${actionId}`);
1563
+ debug(`Awaiting ${description}: ${actionId}`);
1234
1564
  });
1235
1565
  }
1236
1566
  /**
@@ -1239,12 +1569,12 @@ var UserActionCoordinator = class {
1239
1569
  handleConfirmation(actionId, value) {
1240
1570
  const pending = this.pendingActions.get(actionId);
1241
1571
  if (!pending) {
1242
- warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`);
1572
+ debug(`Unexpected confirmation for ${actionId}`);
1243
1573
  return false;
1244
1574
  }
1245
1575
  this.pendingActions.delete(actionId);
1246
1576
  pending.resolve(value);
1247
- info(`[USER-ACTION] Confirmed: ${actionId}`);
1577
+ debug(`Confirmed: ${actionId}`);
1248
1578
  return true;
1249
1579
  }
1250
1580
  /**
@@ -1253,7 +1583,7 @@ var UserActionCoordinator = class {
1253
1583
  cleanup() {
1254
1584
  for (const [actionId, pending] of this.pendingActions.entries()) {
1255
1585
  pending.reject(new PluginDisconnectedError());
1256
- warn(`[USER-ACTION] Cancelled pending action: ${actionId}`);
1586
+ debug(`Cancelled pending action: ${actionId}`);
1257
1587
  }
1258
1588
  this.pendingActions.clear();
1259
1589
  }
@@ -1301,14 +1631,14 @@ function toPackageName(name) {
1301
1631
  return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1302
1632
  }
1303
1633
  function toDirName(name) {
1304
- return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1634
+ return name.replace(/[^a-zA-Z0-9- ]/g, "-").replace(/^[-\s]+|[-\s]+$/g, "").replace(/-+/g, "-");
1305
1635
  }
1306
1636
  async function getProjectHashFromCwd() {
1307
1637
  try {
1308
1638
  const packageJsonPath = path.join(process.cwd(), "package.json");
1309
1639
  const content = await fs.readFile(packageJsonPath, "utf-8");
1310
1640
  const pkg = JSON.parse(content);
1311
- return pkg.framerProjectId ?? null;
1641
+ return pkg.shortProjectHash ?? null;
1312
1642
  } catch {
1313
1643
  return null;
1314
1644
  }
@@ -1325,13 +1655,15 @@ async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
1325
1655
  if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
1326
1656
  const dirName = toDirName(projectName);
1327
1657
  const pkgName = toPackageName(projectName);
1328
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
1658
+ const shortId = shortProjectHash(projectHash);
1659
+ const projectDir = path.join(cwd, dirName || shortId);
1329
1660
  await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
1330
1661
  const pkg = {
1331
- name: pkgName || projectHash,
1662
+ name: pkgName || shortId,
1332
1663
  version: "1.0.0",
1333
1664
  private: true,
1334
- framerProjectId: projectHash,
1665
+ shortProjectHash: shortId,
1666
+ framerProjectHash: projectHash,
1335
1667
  framerProjectName: projectName
1336
1668
  };
1337
1669
  await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
@@ -1352,7 +1684,8 @@ async function matchesProject(packageJsonPath, projectHash) {
1352
1684
  try {
1353
1685
  const content = await fs.readFile(packageJsonPath, "utf-8");
1354
1686
  const pkg = JSON.parse(content);
1355
- return pkg.framerProjectId === projectHash;
1687
+ const inputShort = shortProjectHash(projectHash);
1688
+ return pkg.shortProjectHash === inputShort;
1356
1689
  } catch {
1357
1690
  return false;
1358
1691
  }
@@ -1400,7 +1733,7 @@ function transition(state, event) {
1400
1733
  };
1401
1734
  }
1402
1735
  case "FILE_SYNCED": {
1403
- effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
1736
+ effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
1404
1737
  type: "UPDATE_FILE_METADATA",
1405
1738
  fileName: event.fileName,
1406
1739
  remoteModifiedAt: event.remoteModifiedAt
@@ -1411,7 +1744,7 @@ function transition(state, event) {
1411
1744
  };
1412
1745
  }
1413
1746
  case "DISCONNECT": {
1414
- effects.push({ type: "PERSIST_STATE" }, log("info", "Disconnected, persisting state"));
1747
+ effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
1415
1748
  if (state.mode === "conflict_resolution") {
1416
1749
  const { pendingConflicts: _discarded,...rest } = state;
1417
1750
  return {
@@ -1440,7 +1773,7 @@ function transition(state, event) {
1440
1773
  effects
1441
1774
  };
1442
1775
  }
1443
- effects.push(log("info", "Plugin requested file list"), { type: "LIST_LOCAL_FILES" });
1776
+ effects.push(log("debug", "Plugin requested file list"), { type: "LIST_LOCAL_FILES" });
1444
1777
  return {
1445
1778
  state,
1446
1779
  effects
@@ -1454,7 +1787,7 @@ function transition(state, event) {
1454
1787
  effects
1455
1788
  };
1456
1789
  }
1457
- effects.push(log("info", `Received file list: ${event.files.length} files`));
1790
+ effects.push(log("debug", `Received file list: ${event.files.length} files`));
1458
1791
  effects.push({
1459
1792
  type: "DETECT_CONFLICTS",
1460
1793
  remoteFiles: event.files
@@ -1477,12 +1810,13 @@ function transition(state, event) {
1477
1810
  };
1478
1811
  }
1479
1812
  const { conflicts, safeWrites, localOnly } = event;
1480
- if (safeWrites.length > 0) effects.push(log("info", `Applying ${safeWrites.length} safe writes`), {
1813
+ if (safeWrites.length > 0) effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), {
1481
1814
  type: "WRITE_FILES",
1482
- files: safeWrites
1815
+ files: safeWrites,
1816
+ silent: true
1483
1817
  });
1484
1818
  if (localOnly.length > 0) {
1485
- effects.push(log("info", `Uploading ${localOnly.length} local-only files`));
1819
+ effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
1486
1820
  for (const file of localOnly) effects.push({
1487
1821
  type: "SEND_MESSAGE",
1488
1822
  payload: {
@@ -1493,7 +1827,7 @@ function transition(state, event) {
1493
1827
  });
1494
1828
  }
1495
1829
  if (conflicts.length > 0) {
1496
- effects.push(log("warn", `${conflicts.length} conflicts require version verification`), log("info", "[CONFLICTS] Requesting remote version data from plugin..."), {
1830
+ effects.push(log("debug", `${pluralize(conflicts.length, "conflict")} require version check`), {
1497
1831
  type: "REQUEST_CONFLICT_VERSIONS",
1498
1832
  conflicts
1499
1833
  });
@@ -1506,7 +1840,17 @@ function transition(state, event) {
1506
1840
  effects
1507
1841
  };
1508
1842
  }
1509
- effects.push(log("info", "Initial sync complete, entering watch mode"), { type: "PERSIST_STATE" });
1843
+ const totalSynced = safeWrites.length + localOnly.length;
1844
+ const remoteTotal = state.queuedDiffs.length;
1845
+ const totalCount = remoteTotal + localOnly.length;
1846
+ const updatedCount = safeWrites.length + localOnly.length;
1847
+ const unchangedCount = Math.max(0, remoteTotal - safeWrites.length);
1848
+ effects.push({ type: "PERSIST_STATE" }, {
1849
+ type: "SYNC_COMPLETE",
1850
+ totalCount,
1851
+ updatedCount,
1852
+ unchangedCount
1853
+ });
1510
1854
  return {
1511
1855
  state: {
1512
1856
  ...state,
@@ -1535,7 +1879,7 @@ function transition(state, event) {
1535
1879
  effects
1536
1880
  };
1537
1881
  }
1538
- effects.push(log("info", `Applying remote change: ${event.file.name}`), {
1882
+ effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
1539
1883
  type: "WRITE_FILES",
1540
1884
  files: [event.file]
1541
1885
  });
@@ -1552,7 +1896,7 @@ function transition(state, event) {
1552
1896
  effects
1553
1897
  };
1554
1898
  }
1555
- effects.push(log("info", `Remote delete applied: ${event.fileName}`), {
1899
+ effects.push(log("debug", `Remote delete applied: ${event.fileName}`), {
1556
1900
  type: "DELETE_LOCAL_FILES",
1557
1901
  names: [event.fileName]
1558
1902
  }, { type: "PERSIST_STATE" });
@@ -1562,7 +1906,7 @@ function transition(state, event) {
1562
1906
  };
1563
1907
  }
1564
1908
  case "REMOTE_DELETE_CONFIRMED": {
1565
- effects.push(log("info", `Delete confirmed: ${event.fileName}`), {
1909
+ effects.push(log("debug", `Delete confirmed: ${event.fileName}`), {
1566
1910
  type: "DELETE_LOCAL_FILES",
1567
1911
  names: [event.fileName]
1568
1912
  }, { type: "PERSIST_STATE" });
@@ -1572,7 +1916,7 @@ function transition(state, event) {
1572
1916
  };
1573
1917
  }
1574
1918
  case "REMOTE_DELETE_CANCELLED": {
1575
- effects.push(log("info", `Delete cancelled: ${event.fileName}`));
1919
+ effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
1576
1920
  effects.push({
1577
1921
  type: "WRITE_FILES",
1578
1922
  files: [{
@@ -1595,18 +1939,28 @@ function transition(state, event) {
1595
1939
  };
1596
1940
  }
1597
1941
  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`), {
1942
+ for (const conflict of state.pendingConflicts) if (conflict.remoteContent === null) effects.push({
1943
+ type: "DELETE_LOCAL_FILES",
1944
+ names: [conflict.fileName]
1945
+ });
1946
+ else effects.push({
1604
1947
  type: "WRITE_FILES",
1605
- files: remoteFiles
1948
+ files: [{
1949
+ name: conflict.fileName,
1950
+ content: conflict.remoteContent,
1951
+ modifiedAt: conflict.remoteModifiedAt
1952
+ }]
1606
1953
  });
1954
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} remote versions`));
1607
1955
  } else {
1608
- effects.push(log("info", `Applying ${state.pendingConflicts.length} local versions`));
1609
- for (const conflict of state.pendingConflicts) effects.push({
1956
+ for (const conflict of state.pendingConflicts) if (conflict.localContent === null) effects.push({
1957
+ type: "SEND_MESSAGE",
1958
+ payload: {
1959
+ type: "file-delete",
1960
+ fileNames: [conflict.fileName]
1961
+ }
1962
+ });
1963
+ else effects.push({
1610
1964
  type: "SEND_MESSAGE",
1611
1965
  payload: {
1612
1966
  type: "file-change",
@@ -1614,8 +1968,14 @@ function transition(state, event) {
1614
1968
  content: conflict.localContent
1615
1969
  }
1616
1970
  });
1971
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} local versions`));
1617
1972
  }
1618
- effects.push(log("info", "All conflicts resolved, entering watch mode"), { type: "PERSIST_STATE" });
1973
+ effects.push({ type: "PERSIST_STATE" }, {
1974
+ type: "SYNC_COMPLETE",
1975
+ totalCount: state.pendingConflicts.length,
1976
+ updatedCount: state.pendingConflicts.length,
1977
+ unchangedCount: 0
1978
+ });
1619
1979
  const { pendingConflicts: _discarded,...rest } = state;
1620
1980
  return {
1621
1981
  state: {
@@ -1644,7 +2004,6 @@ function transition(state, event) {
1644
2004
  effects
1645
2005
  };
1646
2006
  }
1647
- effects.push(log("info", `Local change detected: ${relativePath}`));
1648
2007
  effects.push({
1649
2008
  type: "SEND_LOCAL_CHANGE",
1650
2009
  fileName: relativePath,
@@ -1653,7 +2012,7 @@ function transition(state, event) {
1653
2012
  break;
1654
2013
  }
1655
2014
  case "delete": {
1656
- effects.push(log("info", `Local delete detected: ${relativePath}`), {
2015
+ effects.push(log("debug", `Local delete detected: ${relativePath}`), {
1657
2016
  type: "REQUEST_LOCAL_DELETE_DECISION",
1658
2017
  fileName: relativePath,
1659
2018
  requireConfirmation: true
@@ -1676,23 +2035,37 @@ function transition(state, event) {
1676
2035
  }
1677
2036
  const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
1678
2037
  if (autoResolvedLocal.length > 0) {
1679
- effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`));
1680
- for (const conflict of autoResolvedLocal) effects.push({
2038
+ effects.push(log("debug", `Auto-resolved ${autoResolvedLocal.length} local changes`));
2039
+ for (const conflict of autoResolvedLocal) if (conflict.localContent === null) effects.push({
2040
+ type: "SEND_MESSAGE",
2041
+ payload: {
2042
+ type: "file-delete",
2043
+ fileNames: [conflict.fileName]
2044
+ }
2045
+ });
2046
+ else effects.push({
1681
2047
  type: "SEND_LOCAL_CHANGE",
1682
2048
  fileName: conflict.fileName,
1683
2049
  content: conflict.localContent
1684
2050
  });
1685
2051
  }
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
- });
2052
+ if (autoResolvedRemote.length > 0) {
2053
+ effects.push(log("debug", `Auto-resolved ${autoResolvedRemote.length} remote changes`));
2054
+ for (const conflict of autoResolvedRemote) if (conflict.remoteContent === null) effects.push({
2055
+ type: "DELETE_LOCAL_FILES",
2056
+ names: [conflict.fileName]
2057
+ });
2058
+ else effects.push({
2059
+ type: "WRITE_FILES",
2060
+ files: [{
2061
+ name: conflict.fileName,
2062
+ content: conflict.remoteContent,
2063
+ modifiedAt: conflict.remoteModifiedAt ?? Date.now()
2064
+ }]
2065
+ });
2066
+ }
1694
2067
  if (remainingConflicts.length > 0) {
1695
- effects.push(log("warn", `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`), {
2068
+ effects.push(log("warn", `${pluralize(remainingConflicts.length, "conflict")} require resolution`), {
1696
2069
  type: "REQUEST_CONFLICT_DECISIONS",
1697
2070
  conflicts: remainingConflicts
1698
2071
  });
@@ -1704,7 +2077,13 @@ function transition(state, event) {
1704
2077
  effects
1705
2078
  };
1706
2079
  }
1707
- effects.push(log("info", "[AUTO-RESOLVE] All conflicts auto-resolved!"), { type: "PERSIST_STATE" });
2080
+ const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length;
2081
+ effects.push({ type: "PERSIST_STATE" }, {
2082
+ type: "SYNC_COMPLETE",
2083
+ totalCount: resolvedCount,
2084
+ updatedCount: resolvedCount,
2085
+ unchangedCount: 0
2086
+ });
1708
2087
  const { pendingConflicts: _discarded,...rest } = state;
1709
2088
  return {
1710
2089
  state: {
@@ -1736,7 +2115,7 @@ async function executeEffect(effect, context) {
1736
2115
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
1737
2116
  config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir);
1738
2117
  config.filesDir = `${config.projectDir}/files`;
1739
- info(`Files directory: ${config.filesDir}`);
2118
+ debug(`Files directory: ${config.filesDir}`);
1740
2119
  await fs.mkdir(config.filesDir, { recursive: true });
1741
2120
  }
1742
2121
  return [];
@@ -1744,7 +2123,7 @@ async function executeEffect(effect, context) {
1744
2123
  case "LOAD_PERSISTED_STATE": {
1745
2124
  if (config.projectDir) {
1746
2125
  await fileMetadataCache.initialize(config.projectDir);
1747
- info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2126
+ debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
1748
2127
  }
1749
2128
  return [];
1750
2129
  }
@@ -1778,6 +2157,7 @@ async function executeEffect(effect, context) {
1778
2157
  if (config.filesDir) {
1779
2158
  await writeRemoteFiles(effect.files, config.filesDir, hashTracker, installer ?? void 0);
1780
2159
  for (const file of effect.files) {
2160
+ if (!effect.silent) fileDown(file.name);
1781
2161
  const remoteTimestamp = file.modifiedAt ?? Date.now();
1782
2162
  fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
1783
2163
  }
@@ -1787,6 +2167,7 @@ async function executeEffect(effect, context) {
1787
2167
  case "DELETE_LOCAL_FILES": {
1788
2168
  if (config.filesDir) for (const fileName of effect.names) {
1789
2169
  await deleteLocalFile(fileName, config.filesDir, hashTracker);
2170
+ fileDelete(fileName);
1790
2171
  fileMetadataCache.recordDelete(fileName);
1791
2172
  }
1792
2173
  return [];
@@ -1808,7 +2189,7 @@ async function executeEffect(effect, context) {
1808
2189
  lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp
1809
2190
  };
1810
2191
  });
1811
- info(`[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`);
2192
+ debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
1812
2193
  await sendMessage(syncState.socket, {
1813
2194
  type: "conflict-version-request",
1814
2195
  conflicts: versionRequests
@@ -1833,17 +2214,27 @@ async function executeEffect(effect, context) {
1833
2214
  return [];
1834
2215
  }
1835
2216
  case "SEND_LOCAL_CHANGE": {
2217
+ const contentHash = hashFileContent(effect.content);
2218
+ const metadata = fileMetadataCache.get(effect.fileName);
2219
+ if (metadata?.lastSyncedHash === contentHash) {
2220
+ debug(`Skipping local change for ${effect.fileName}: matches last synced content`);
2221
+ return [];
2222
+ }
1836
2223
  if (hashTracker.shouldSkip(effect.fileName, effect.content)) return [];
2224
+ debug(`Local change detected: ${effect.fileName}`);
1837
2225
  try {
1838
- if (syncState.socket) await sendMessage(syncState.socket, {
1839
- type: "file-change",
1840
- fileName: effect.fileName,
1841
- content: effect.content
1842
- });
2226
+ if (syncState.socket) {
2227
+ await sendMessage(syncState.socket, {
2228
+ type: "file-change",
2229
+ fileName: effect.fileName,
2230
+ content: effect.content
2231
+ });
2232
+ fileUp(effect.fileName);
2233
+ }
1843
2234
  hashTracker.remember(effect.fileName, effect.content);
1844
2235
  if (installer) installer.process(effect.fileName, effect.content);
1845
2236
  } catch (err) {
1846
- console.warn(`Failed to push change for ${effect.fileName}, will re-sync on next diff:`, err);
2237
+ warn(`Failed to push ${effect.fileName}`);
1847
2238
  }
1848
2239
  return [];
1849
2240
  }
@@ -1875,6 +2266,20 @@ async function executeEffect(effect, context) {
1875
2266
  await fileMetadataCache.flush();
1876
2267
  return [];
1877
2268
  }
2269
+ case "SYNC_COMPLETE": {
2270
+ const wasDisconnected = wasRecentlyDisconnected();
2271
+ if (wasDisconnected) {
2272
+ if (didShowDisconnect()) {
2273
+ success(`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2274
+ status("Watching for changes...");
2275
+ }
2276
+ resetDisconnectState();
2277
+ return [];
2278
+ }
2279
+ success(`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`);
2280
+ status("Watching for changes...");
2281
+ return [];
2282
+ }
1878
2283
  case "LOG": {
1879
2284
  const logFn = effect.level === "info" ? info : effect.level === "warn" ? warn : debug;
1880
2285
  logFn(effect.message);
@@ -1886,9 +2291,7 @@ async function executeEffect(effect, context) {
1886
2291
  * Starts the sync controller with the given configuration
1887
2292
  */
1888
2293
  async function start(config) {
1889
- info("🚀 Starting Code Link");
1890
- info(`Project: ${config.projectHash}`);
1891
- info(`Port: ${config.port} (auto-selected from project hash)`);
2294
+ status("Waiting for Plugin connection...");
1892
2295
  const hashTracker = createHashTracker();
1893
2296
  const fileMetadataCache = new FileMetadataCache();
1894
2297
  let installer = null;
@@ -1902,13 +2305,13 @@ async function start(config) {
1902
2305
  const userActions = new UserActionCoordinator();
1903
2306
  async function processEvent(event) {
1904
2307
  const socketState = syncState.socket?.readyState;
1905
- info(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
2308
+ debug(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
1906
2309
  const result = transition(syncState, event);
1907
2310
  syncState = result.state;
1908
- if (result.effects.length > 0) info(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
2311
+ if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
1909
2312
  for (const effect of result.effects) {
1910
2313
  const currentSocketState = syncState.socket?.readyState;
1911
- if (currentSocketState !== void 0 && currentSocketState !== 1) warn(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
2314
+ if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
1912
2315
  const followUpEvents = await executeEffect(effect, {
1913
2316
  config,
1914
2317
  hashTracker,
@@ -1922,10 +2325,11 @@ async function start(config) {
1922
2325
  }
1923
2326
  const connection = await initConnection(config.port);
1924
2327
  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}`);
2328
+ debug(`Received handshake: ${message.projectName} (${message.projectId})`);
2329
+ const expectedShort = shortProjectHash(config.projectHash);
2330
+ const receivedShort = shortProjectHash(message.projectId);
2331
+ if (receivedShort !== expectedShort) {
2332
+ warn(`Project ID mismatch: expected ${expectedShort}, got ${receivedShort}`);
1929
2333
  client.close();
1930
2334
  return;
1931
2335
  }
@@ -1942,7 +2346,9 @@ async function start(config) {
1942
2346
  await installer.initialize();
1943
2347
  startWatcher();
1944
2348
  }
1945
- success("Handshake successful - connection established");
2349
+ cancelDisconnectMessage();
2350
+ const wasDisconnected = wasRecentlyDisconnected();
2351
+ if (!wasDisconnected && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
1946
2352
  });
1947
2353
  async function handleMessage(message) {
1948
2354
  if (!config.projectDir || !installer) {
@@ -1956,7 +2362,7 @@ async function start(config) {
1956
2362
  break;
1957
2363
  case "file-list": {
1958
2364
  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)`);
2365
+ debug(`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`);
1960
2366
  event = {
1961
2367
  type: "FILE_LIST",
1962
2368
  files: message.files
@@ -2037,10 +2443,11 @@ async function start(config) {
2037
2443
  }
2038
2444
  });
2039
2445
  connection.on("disconnect", async () => {
2040
- warn("Plugin disconnected");
2446
+ scheduleDisconnectMessage(() => {
2447
+ status("Disconnected, waiting to reconnect...");
2448
+ });
2041
2449
  await processEvent({ type: "DISCONNECT" });
2042
2450
  userActions.cleanup();
2043
- info("Will perform full diff on reconnect");
2044
2451
  });
2045
2452
  connection.on("error", (err) => {
2046
2453
  error("Error on WebSocket connection:", err);
@@ -2056,10 +2463,9 @@ async function start(config) {
2056
2463
  });
2057
2464
  });
2058
2465
  };
2059
- info("✓ Controller initialized and ready");
2060
- info(`Waiting for plugin connection on port ${config.port}...`);
2061
2466
  process.on("SIGINT", async () => {
2062
- info("\nShutting down gracefully...");
2467
+ console.log();
2468
+ status("Shutting down...");
2063
2469
  if (watcher) await watcher.close();
2064
2470
  connection.close();
2065
2471
  process.exit(0);
@@ -2098,6 +2504,7 @@ program.name("code-link").description("Sync Framer code components to your local
2098
2504
  if (level !== void 0) setLogLevel(level);
2099
2505
  } else if (options.verbose || isDev) setLogLevel(LogLevel.DEBUG);
2100
2506
  const port = getPortFromHash(projectHash);
2507
+ banner("0.1.3", port);
2101
2508
  const config = {
2102
2509
  port,
2103
2510
  projectHash,
@@ -2107,7 +2514,7 @@ program.name("code-link").description("Sync Framer code components to your local
2107
2514
  explicitDir: options.dir,
2108
2515
  explicitName: options.name
2109
2516
  };
2110
- if (config.dangerouslyAutoDelete) info("⚠️ Auto-delete mode enabled - files will be deleted without confirmation");
2517
+ if (config.dangerouslyAutoDelete) warn("Auto-delete mode enabled - files will be deleted without confirmation");
2111
2518
  try {
2112
2519
  await start(config);
2113
2520
  } catch (err) {