framer-code-link 0.1.2 → 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,77 +115,335 @@ 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
44
283
  //#region src/helpers/connection.ts
45
284
  /**
46
285
  * Initializes a WebSocket server and returns a connection interface
286
+ * Returns a Promise that resolves when the server is ready, or rejects on startup errors
47
287
  */
48
288
  function initConnection(port) {
49
- const wss = new WebSocketServer({ port });
50
- const handlers = {};
51
- info(`WebSocket server listening on port ${port}`);
52
- wss.on("connection", (ws) => {
53
- info("Client connected");
54
- ws.on("message", (data) => {
55
- try {
56
- const message = JSON.parse(data.toString());
57
- if (message.type === "handshake") handlers.onHandshake?.(ws, message);
58
- else handlers.onMessage?.(message);
59
- } catch (err) {
60
- error("Failed to parse message:", err);
289
+ return new Promise((resolve, reject) => {
290
+ const wss = new WebSocketServer({ port });
291
+ const handlers = {};
292
+ let connectionId = 0;
293
+ let isReady = false;
294
+ wss.on("error", (err) => {
295
+ if (!isReady) {
296
+ if (err.code === "EADDRINUSE") {
297
+ error(`Port ${port} is already in use.`);
298
+ error(`This usually means another instance of Code Link is already running.`);
299
+ error(``);
300
+ error(`To fix this:`);
301
+ error(` 1. Close any other terminal running Code Link for this project`);
302
+ error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
303
+ error(` Then kill the process: kill -9 <PID>`);
304
+ reject(new Error(`Port ${port} is already in use`));
305
+ } else {
306
+ error(`Failed to start WebSocket server: ${err.message}`);
307
+ reject(err);
308
+ }
309
+ return;
61
310
  }
311
+ error(`WebSocket server error: ${err.message}`);
62
312
  });
63
- ws.on("close", () => {
64
- info("Client disconnected");
65
- handlers.onDisconnect?.();
66
- });
67
- ws.on("error", (err) => {
68
- error("WebSocket error:", err);
313
+ wss.on("listening", () => {
314
+ isReady = true;
315
+ debug(`WebSocket server listening on port ${port}`);
316
+ wss.on("connection", (ws) => {
317
+ const connId = ++connectionId;
318
+ debug(`Client connected (conn ${connId})`);
319
+ ws.on("message", (data) => {
320
+ try {
321
+ const message = JSON.parse(data.toString());
322
+ if (message.type === "handshake") {
323
+ debug(`Received handshake (conn ${connId})`);
324
+ handlers.onHandshake?.(ws, message);
325
+ } else handlers.onMessage?.(message);
326
+ } catch (err) {
327
+ error(`Failed to parse message:`, err);
328
+ }
329
+ });
330
+ ws.on("close", (code, reason) => {
331
+ debug(`Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
332
+ handlers.onDisconnect?.();
333
+ });
334
+ ws.on("error", (err) => {
335
+ error(`WebSocket error:`, err);
336
+ });
337
+ });
338
+ resolve({
339
+ on(event, handler) {
340
+ if (event === "handshake") handlers.onHandshake = handler;
341
+ else if (event === "message") handlers.onMessage = handler;
342
+ else if (event === "disconnect") handlers.onDisconnect = handler;
343
+ else if (event === "error") handlers.onError = handler;
344
+ },
345
+ close() {
346
+ wss.close();
347
+ }
348
+ });
69
349
  });
70
350
  });
71
- return {
72
- on(event, handler) {
73
- if (event === "handshake") handlers.onHandshake = handler;
74
- else if (event === "message") handlers.onMessage = handler;
75
- else if (event === "disconnect") handlers.onDisconnect = handler;
76
- else if (event === "error") handlers.onError = handler;
77
- },
78
- close() {
79
- wss.close();
80
- }
81
- };
351
+ }
352
+ /**
353
+ * WebSocket readyState constants for reference
354
+ */
355
+ const READY_STATE = {
356
+ CONNECTING: 0,
357
+ OPEN: 1,
358
+ CLOSING: 2,
359
+ CLOSED: 3
360
+ };
361
+ function readyStateToString(state) {
362
+ switch (state) {
363
+ case 0: return "CONNECTING";
364
+ case 1: return "OPEN";
365
+ case 2: return "CLOSING";
366
+ case 3: return "CLOSED";
367
+ default: return `UNKNOWN(${state})`;
368
+ }
82
369
  }
83
370
  /**
84
371
  * Sends a message to a connected socket
372
+ * Returns false if the socket is not open (instead of throwing)
85
373
  */
86
374
  function sendMessage(socket, message) {
87
- return new Promise((resolve, reject) => {
375
+ return new Promise((resolve) => {
376
+ if (socket.readyState !== READY_STATE.OPEN) {
377
+ const stateStr = readyStateToString(socket.readyState);
378
+ debug(`Cannot send ${message.type}: socket is ${stateStr}`);
379
+ resolve(false);
380
+ return;
381
+ }
88
382
  socket.send(JSON.stringify(message), (err) => {
89
- if (err) reject(err);
90
- else resolve();
383
+ if (err) {
384
+ debug(`Send error for ${message.type}: ${err.message}`);
385
+ resolve(false);
386
+ } else resolve(true);
91
387
  });
92
388
  });
93
389
  }
94
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
+
95
447
  //#endregion
96
448
  //#region ../shared/dist/paths.js
97
449
  /**
@@ -195,6 +547,16 @@ function sanitizeFilePath(input, capitalizeReactComponent = true) {
195
547
  function isSupportedExtension$1(filePath) {
196
548
  return /\.(tsx?|jsx?|json)$/i.test(filePath);
197
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
+ }
198
560
 
199
561
  //#endregion
200
562
  //#region src/utils/paths.ts
@@ -240,7 +602,7 @@ function initWatcher(filesDir) {
240
602
  persistent: true,
241
603
  ignoreInitial: false
242
604
  });
243
- info(`Watching directory: ${filesDir}`);
605
+ debug(`Watching directory: ${filesDir}`);
244
606
  const emitEvent = async (kind, absolutePath) => {
245
607
  if (!isSupportedExtension$1(absolutePath)) return;
246
608
  const rawRelativePath = normalizePath(getRelativePath(filesDir, absolutePath));
@@ -252,10 +614,10 @@ function initWatcher(filesDir) {
252
614
  try {
253
615
  await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true });
254
616
  await fs.rename(absolutePath, newAbsolutePath);
255
- info(`Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`);
617
+ debug(`Renamed ${rawRelativePath} -> ${relativePath}`);
256
618
  effectiveAbsolutePath = newAbsolutePath;
257
619
  } catch (err) {
258
- warn(`Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`, err);
620
+ warn(`Failed to rename ${rawRelativePath}`, err);
259
621
  }
260
622
  }
261
623
  let content;
@@ -366,6 +728,10 @@ const SUPPORTED_EXTENSIONS = [
366
728
  ];
367
729
  const DEFAULT_EXTENSION = ".tsx";
368
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
+ }
369
735
  /**
370
736
  * Lists all supported files in the files directory
371
737
  */
@@ -409,19 +775,34 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
409
775
  const detect = options.detectConflicts ?? true;
410
776
  const preferRemote = options.preferRemote ?? false;
411
777
  const persistedState = options.persistedState;
778
+ const getPersistedState = (fileName) => persistedState?.get(normalizeForComparison(fileName)) ?? persistedState?.get(fileName);
412
779
  debug(`Detecting conflicts for ${remoteFiles.length} remote files`);
413
780
  const localFiles = await listFiles(filesDir);
414
- 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
+ }));
415
786
  const processedFiles = /* @__PURE__ */ new Set();
416
787
  for (const remote of remoteFiles) {
417
788
  const normalized = resolveRemoteReference(filesDir, remote.name);
418
- const local = localFileMap.get(normalized.relativePath);
419
- processedFiles.add(normalized.relativePath);
420
- 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);
421
793
  const localHash = local ? hashFileContent(local.content) : null;
422
794
  const localMatchesPersisted = !!persisted && !!local && localHash === persisted.contentHash;
423
795
  if (!local) {
424
- 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({
425
806
  name: normalized.relativePath,
426
807
  content: remote.content,
427
808
  modifiedAt: remote.modifiedAt
@@ -448,11 +829,32 @@ async function detectConflicts(remoteFiles, filesDir, options = {}) {
448
829
  localClean
449
830
  });
450
831
  }
451
- for (const local of localFiles) if (!processedFiles.has(local.name)) localOnly.push({
452
- name: local.name,
453
- content: local.content,
454
- modifiedAt: local.modifiedAt
455
- });
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
+ }
456
858
  return {
457
859
  conflicts,
458
860
  writes,
@@ -468,30 +870,30 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
468
870
  for (const conflict of conflicts) {
469
871
  const latestRemoteVersionMs = versionMap.get(conflict.fileName);
470
872
  const lastSyncedAt = conflict.lastSyncedAt;
471
- info(`[AUTO-RESOLVE] Checking ${conflict.fileName}...`);
873
+ debug(`Auto-resolve checking ${conflict.fileName}`);
472
874
  if (!latestRemoteVersionMs) {
473
- info(`-> No remote version data, keeping conflict`);
875
+ debug(` No remote version data, keeping conflict`);
474
876
  remainingConflicts.push(conflict);
475
877
  continue;
476
878
  }
477
879
  if (!lastSyncedAt) {
478
- info(`-> No last sync timestamp, keeping conflict`);
880
+ debug(` No last sync timestamp, keeping conflict`);
479
881
  remainingConflicts.push(conflict);
480
882
  continue;
481
883
  }
482
- info(`-> Latest remote: ${new Date(latestRemoteVersionMs).toISOString()} (${latestRemoteVersionMs})`);
483
- info(`-> Last synced: ${new Date(lastSyncedAt).toISOString()} (${lastSyncedAt})`);
884
+ debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`);
885
+ debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`);
484
886
  const remoteUnchanged = latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs;
485
887
  const localClean = conflict.localClean === true;
486
888
  if (remoteUnchanged && !localClean) {
487
- info(` -> Remote unchanged, local changed. Auto-applying LOCAL.`);
889
+ debug(` Remote unchanged, local changed -> LOCAL`);
488
890
  autoResolvedLocal.push(conflict);
489
891
  } else if (localClean && !remoteUnchanged) {
490
- info(` -> Local unchanged, remote changed. Auto-applying REMOTE.`);
892
+ debug(` Local unchanged, remote changed -> REMOTE`);
491
893
  autoResolvedRemote.push(conflict);
492
- } else if (remoteUnchanged && localClean) info(` -> Both unchanged. Skipping (no conflict).`);
894
+ } else if (remoteUnchanged && localClean) debug(` Both unchanged, skipping`);
493
895
  else {
494
- info(` -> Both sides changed. Real conflict.`);
896
+ debug(` Both changed, real conflict`);
495
897
  remainingConflicts.push(conflict);
496
898
  }
497
899
  }
@@ -506,7 +908,7 @@ function autoResolveConflicts(conflicts, versions, options = {}) {
506
908
  * CRITICAL: Update hashTracker BEFORE writing to disk
507
909
  */
508
910
  async function writeRemoteFiles(files, filesDir, hashTracker, installer) {
509
- info(`Writing ${files.length} remote files`);
911
+ debug(`Writing ${files.length} remote files`);
510
912
  for (const file of files) try {
511
913
  const normalized = resolveRemoteReference(filesDir, file.name);
512
914
  const fullPath = normalized.absolutePath;
@@ -528,12 +930,12 @@ async function deleteLocalFile(fileName, filesDir, hashTracker) {
528
930
  hashTracker.markDelete(normalized.relativePath);
529
931
  await fs.unlink(normalized.absolutePath);
530
932
  hashTracker.forget(normalized.relativePath);
531
- info(`Deleted file: ${normalized.relativePath}`);
933
+ debug(`Deleted file: ${normalized.relativePath}`);
532
934
  } catch (err) {
533
935
  const nodeError = err;
534
936
  if (nodeError?.code === "ENOENT") {
535
937
  hashTracker.forget(normalized.relativePath);
536
- info(`File already deleted: ${normalized.relativePath}`);
938
+ debug(`File already deleted: ${normalized.relativePath}`);
537
939
  return;
538
940
  }
539
941
  hashTracker.clearDelete(normalized.relativePath);
@@ -673,13 +1075,13 @@ var Installer = class {
673
1075
  } catch {}
674
1076
  if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
675
1077
  seenPackages.add(pkgMatch[1]);
676
- info(`📦 Types: ${pkgMatch[1]}`);
1078
+ debug(`📦 Types: ${pkgMatch[1]}`);
677
1079
  }
678
1080
  await this.writeTypeFile(receivedPath, code);
679
1081
  }
680
1082
  }
681
1083
  });
682
- info("Type installer initialized");
1084
+ debug("Type installer initialized");
683
1085
  }
684
1086
  /**
685
1087
  * Ensure the project scaffolding exists (tsconfig, declarations, etc.)
@@ -727,7 +1129,7 @@ var Installer = class {
727
1129
  const hash = imports.map((imp) => imp.name).sort().join(",");
728
1130
  if (this.processedImports.has(hash)) return;
729
1131
  this.processedImports.add(hash);
730
- info(`Processing imports for ${fileName} (${imports.length} packages)`);
1132
+ debug(`Processing imports for ${fileName} (${imports.length} packages)`);
731
1133
  try {
732
1134
  await this.ata(content);
733
1135
  } catch (err) {
@@ -822,7 +1224,7 @@ var Installer = class {
822
1224
  include: ["files/**/*", "framer-modules.d.ts"]
823
1225
  };
824
1226
  await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2));
825
- info("Created tsconfig.json");
1227
+ debug("Created tsconfig.json");
826
1228
  }
827
1229
  }
828
1230
  async ensurePrettierConfig() {
@@ -837,7 +1239,7 @@ var Installer = class {
837
1239
  trailingComma: "es5"
838
1240
  };
839
1241
  await fs.writeFile(prettierPath, JSON.stringify(config, null, 2));
840
- info("Created .prettierrc");
1242
+ debug("Created .prettierrc");
841
1243
  }
842
1244
  }
843
1245
  async ensureFramerDeclarations() {
@@ -854,7 +1256,7 @@ declare module "https://framerusercontent.com/*"
854
1256
  declare module "*.json"
855
1257
  `;
856
1258
  await fs.writeFile(declarationsPath, declarations);
857
- info("Created framer-modules.d.ts");
1259
+ debug("Created framer-modules.d.ts");
858
1260
  }
859
1261
  }
860
1262
  async ensurePackageJson() {
@@ -870,7 +1272,7 @@ declare module "*.json"
870
1272
  description: "Framer files synced with framer-code-link"
871
1273
  };
872
1274
  await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2));
873
- info("Created package.json");
1275
+ debug("Created package.json");
874
1276
  }
875
1277
  }
876
1278
  async ensureReact18Types() {
@@ -882,9 +1284,9 @@ declare module "*.json"
882
1284
  "jsx-runtime.d.ts",
883
1285
  "jsx-dev-runtime.d.ts"
884
1286
  ];
885
- 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)");
886
1288
  else {
887
- info("Downloading React 18 types for Framer compatibility...");
1289
+ debug("Downloading React 18 types...");
888
1290
  await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles);
889
1291
  }
890
1292
  const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom");
@@ -893,7 +1295,7 @@ declare module "*.json"
893
1295
  "index.d.ts",
894
1296
  "client.d.ts"
895
1297
  ];
896
- 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)");
897
1299
  else await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles);
898
1300
  }
899
1301
  async hasTypePackage(destinationDir, version, files) {
@@ -958,7 +1360,7 @@ async function fetchWithRetry(url, init, retries = MAX_FETCH_RETRIES) {
958
1360
  }
959
1361
 
960
1362
  //#endregion
961
- //#region src/utils/hashing.ts
1363
+ //#region src/utils/hash-tracker.ts
962
1364
  /**
963
1365
  * Creates a hash tracker instance for echo prevention
964
1366
  */
@@ -1000,26 +1402,11 @@ function createHashTracker() {
1000
1402
  };
1001
1403
  }
1002
1404
  /**
1003
- * Computes a hash of file content for comparison
1405
+ * Computes a SHA256 hash of file content for comparison
1004
1406
  */
1005
1407
  function hashContent(content) {
1006
1408
  return createHash("sha256").update(content).digest("hex");
1007
1409
  }
1008
- /**
1009
- * Generate a deterministic port number from a project hash
1010
- * Port range: 3847-4096 (250 possible ports)
1011
- * Uses simple hash to match client-side implementation
1012
- */
1013
- function getPortFromHash(projectHash) {
1014
- let hash = 0;
1015
- for (let i = 0; i < projectHash.length; i++) {
1016
- const char = projectHash.charCodeAt(i);
1017
- hash = (hash << 5) - hash + char;
1018
- hash = hash & hash;
1019
- }
1020
- const portOffset = Math.abs(hash) % 250;
1021
- return 3847 + portOffset;
1022
- }
1023
1410
 
1024
1411
  //#endregion
1025
1412
  //#region src/utils/file-metadata-cache.ts
@@ -1126,7 +1513,7 @@ var UserActionCoordinator = class {
1126
1513
  return await confirmationPromise;
1127
1514
  } catch (err) {
1128
1515
  if (err instanceof PluginDisconnectedError) {
1129
- info(`[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1516
+ debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`);
1130
1517
  return false;
1131
1518
  }
1132
1519
  throw err;
@@ -1158,7 +1545,7 @@ var UserActionCoordinator = class {
1158
1545
  return new Map(results);
1159
1546
  } catch (err) {
1160
1547
  if (err instanceof PluginDisconnectedError) {
1161
- info("[USER-ACTION] Plugin disconnected while awaiting conflict decisions");
1548
+ debug("Plugin disconnected while awaiting conflict decisions");
1162
1549
  return /* @__PURE__ */ new Map();
1163
1550
  }
1164
1551
  throw err;
@@ -1173,7 +1560,7 @@ var UserActionCoordinator = class {
1173
1560
  resolve,
1174
1561
  reject
1175
1562
  });
1176
- info(`[USER-ACTION] Awaiting ${description}: ${actionId}`);
1563
+ debug(`Awaiting ${description}: ${actionId}`);
1177
1564
  });
1178
1565
  }
1179
1566
  /**
@@ -1182,12 +1569,12 @@ var UserActionCoordinator = class {
1182
1569
  handleConfirmation(actionId, value) {
1183
1570
  const pending = this.pendingActions.get(actionId);
1184
1571
  if (!pending) {
1185
- warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`);
1572
+ debug(`Unexpected confirmation for ${actionId}`);
1186
1573
  return false;
1187
1574
  }
1188
1575
  this.pendingActions.delete(actionId);
1189
1576
  pending.resolve(value);
1190
- info(`[USER-ACTION] Confirmed: ${actionId}`);
1577
+ debug(`Confirmed: ${actionId}`);
1191
1578
  return true;
1192
1579
  }
1193
1580
  /**
@@ -1196,7 +1583,7 @@ var UserActionCoordinator = class {
1196
1583
  cleanup() {
1197
1584
  for (const [actionId, pending] of this.pendingActions.entries()) {
1198
1585
  pending.reject(new PluginDisconnectedError());
1199
- warn(`[USER-ACTION] Cancelled pending action: ${actionId}`);
1586
+ debug(`Cancelled pending action: ${actionId}`);
1200
1587
  }
1201
1588
  this.pendingActions.clear();
1202
1589
  }
@@ -1243,6 +1630,19 @@ function validateIncomingChange(file, fileMeta, currentMode) {
1243
1630
  function toPackageName(name) {
1244
1631
  return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
1245
1632
  }
1633
+ function toDirName(name) {
1634
+ return name.replace(/[^a-zA-Z0-9- ]/g, "-").replace(/^[-\s]+|[-\s]+$/g, "").replace(/-+/g, "-");
1635
+ }
1636
+ async function getProjectHashFromCwd() {
1637
+ try {
1638
+ const packageJsonPath = path.join(process.cwd(), "package.json");
1639
+ const content = await fs.readFile(packageJsonPath, "utf-8");
1640
+ const pkg = JSON.parse(content);
1641
+ return pkg.shortProjectHash ?? null;
1642
+ } catch {
1643
+ return null;
1644
+ }
1645
+ }
1246
1646
  async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
1247
1647
  if (explicitDir) {
1248
1648
  const resolved = path.resolve(explicitDir);
@@ -1253,14 +1653,17 @@ async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
1253
1653
  const existing = await findExistingProjectDir(cwd, projectHash);
1254
1654
  if (existing) return existing;
1255
1655
  if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
1256
- const dirName = toPackageName(projectName);
1257
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
1656
+ const dirName = toDirName(projectName);
1657
+ const pkgName = toPackageName(projectName);
1658
+ const shortId = shortProjectHash(projectHash);
1659
+ const projectDir = path.join(cwd, dirName || shortId);
1258
1660
  await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
1259
1661
  const pkg = {
1260
- name: dirName || projectHash,
1662
+ name: pkgName || shortId,
1261
1663
  version: "1.0.0",
1262
1664
  private: true,
1263
- framerProjectId: projectHash,
1665
+ shortProjectHash: shortId,
1666
+ framerProjectHash: projectHash,
1264
1667
  framerProjectName: projectName
1265
1668
  };
1266
1669
  await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
@@ -1281,7 +1684,8 @@ async function matchesProject(packageJsonPath, projectHash) {
1281
1684
  try {
1282
1685
  const content = await fs.readFile(packageJsonPath, "utf-8");
1283
1686
  const pkg = JSON.parse(content);
1284
- return pkg.framerProjectId === projectHash;
1687
+ const inputShort = shortProjectHash(projectHash);
1688
+ return pkg.shortProjectHash === inputShort;
1285
1689
  } catch {
1286
1690
  return false;
1287
1691
  }
@@ -1329,7 +1733,7 @@ function transition(state, event) {
1329
1733
  };
1330
1734
  }
1331
1735
  case "FILE_SYNCED": {
1332
- effects.push(log("info", `Remote confirmed sync: ${event.fileName}`), {
1736
+ effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), {
1333
1737
  type: "UPDATE_FILE_METADATA",
1334
1738
  fileName: event.fileName,
1335
1739
  remoteModifiedAt: event.remoteModifiedAt
@@ -1340,7 +1744,7 @@ function transition(state, event) {
1340
1744
  };
1341
1745
  }
1342
1746
  case "DISCONNECT": {
1343
- effects.push({ type: "PERSIST_STATE" }, log("info", "Disconnected, persisting state"));
1747
+ effects.push({ type: "PERSIST_STATE" }, log("debug", "Disconnected, persisting state"));
1344
1748
  if (state.mode === "conflict_resolution") {
1345
1749
  const { pendingConflicts: _discarded,...rest } = state;
1346
1750
  return {
@@ -1369,7 +1773,7 @@ function transition(state, event) {
1369
1773
  effects
1370
1774
  };
1371
1775
  }
1372
- 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" });
1373
1777
  return {
1374
1778
  state,
1375
1779
  effects
@@ -1383,7 +1787,7 @@ function transition(state, event) {
1383
1787
  effects
1384
1788
  };
1385
1789
  }
1386
- effects.push(log("info", `Received file list: ${event.files.length} files`));
1790
+ effects.push(log("debug", `Received file list: ${event.files.length} files`));
1387
1791
  effects.push({
1388
1792
  type: "DETECT_CONFLICTS",
1389
1793
  remoteFiles: event.files
@@ -1406,12 +1810,13 @@ function transition(state, event) {
1406
1810
  };
1407
1811
  }
1408
1812
  const { conflicts, safeWrites, localOnly } = event;
1409
- 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`), {
1410
1814
  type: "WRITE_FILES",
1411
- files: safeWrites
1815
+ files: safeWrites,
1816
+ silent: true
1412
1817
  });
1413
1818
  if (localOnly.length > 0) {
1414
- effects.push(log("info", `Uploading ${localOnly.length} local-only files`));
1819
+ effects.push(log("debug", `Uploading ${localOnly.length} local-only files`));
1415
1820
  for (const file of localOnly) effects.push({
1416
1821
  type: "SEND_MESSAGE",
1417
1822
  payload: {
@@ -1422,7 +1827,7 @@ function transition(state, event) {
1422
1827
  });
1423
1828
  }
1424
1829
  if (conflicts.length > 0) {
1425
- 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`), {
1426
1831
  type: "REQUEST_CONFLICT_VERSIONS",
1427
1832
  conflicts
1428
1833
  });
@@ -1435,7 +1840,17 @@ function transition(state, event) {
1435
1840
  effects
1436
1841
  };
1437
1842
  }
1438
- 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
+ });
1439
1854
  return {
1440
1855
  state: {
1441
1856
  ...state,
@@ -1464,7 +1879,7 @@ function transition(state, event) {
1464
1879
  effects
1465
1880
  };
1466
1881
  }
1467
- effects.push(log("info", `Applying remote change: ${event.file.name}`), {
1882
+ effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
1468
1883
  type: "WRITE_FILES",
1469
1884
  files: [event.file]
1470
1885
  });
@@ -1481,7 +1896,7 @@ function transition(state, event) {
1481
1896
  effects
1482
1897
  };
1483
1898
  }
1484
- effects.push(log("info", `Remote delete applied: ${event.fileName}`), {
1899
+ effects.push(log("debug", `Remote delete applied: ${event.fileName}`), {
1485
1900
  type: "DELETE_LOCAL_FILES",
1486
1901
  names: [event.fileName]
1487
1902
  }, { type: "PERSIST_STATE" });
@@ -1491,7 +1906,7 @@ function transition(state, event) {
1491
1906
  };
1492
1907
  }
1493
1908
  case "REMOTE_DELETE_CONFIRMED": {
1494
- effects.push(log("info", `Delete confirmed: ${event.fileName}`), {
1909
+ effects.push(log("debug", `Delete confirmed: ${event.fileName}`), {
1495
1910
  type: "DELETE_LOCAL_FILES",
1496
1911
  names: [event.fileName]
1497
1912
  }, { type: "PERSIST_STATE" });
@@ -1501,7 +1916,7 @@ function transition(state, event) {
1501
1916
  };
1502
1917
  }
1503
1918
  case "REMOTE_DELETE_CANCELLED": {
1504
- effects.push(log("info", `Delete cancelled: ${event.fileName}`));
1919
+ effects.push(log("debug", `Delete cancelled: ${event.fileName}`));
1505
1920
  effects.push({
1506
1921
  type: "WRITE_FILES",
1507
1922
  files: [{
@@ -1524,18 +1939,28 @@ function transition(state, event) {
1524
1939
  };
1525
1940
  }
1526
1941
  if (event.resolution === "remote") {
1527
- const remoteFiles = state.pendingConflicts.map((c) => ({
1528
- name: c.fileName,
1529
- content: c.remoteContent,
1530
- modifiedAt: c.remoteModifiedAt
1531
- }));
1532
- 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({
1533
1947
  type: "WRITE_FILES",
1534
- files: remoteFiles
1948
+ files: [{
1949
+ name: conflict.fileName,
1950
+ content: conflict.remoteContent,
1951
+ modifiedAt: conflict.remoteModifiedAt
1952
+ }]
1535
1953
  });
1954
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} remote versions`));
1536
1955
  } else {
1537
- effects.push(log("info", `Applying ${state.pendingConflicts.length} local versions`));
1538
- 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({
1539
1964
  type: "SEND_MESSAGE",
1540
1965
  payload: {
1541
1966
  type: "file-change",
@@ -1543,8 +1968,14 @@ function transition(state, event) {
1543
1968
  content: conflict.localContent
1544
1969
  }
1545
1970
  });
1971
+ effects.push(log("debug", `Applied ${state.pendingConflicts.length} local versions`));
1546
1972
  }
1547
- 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
+ });
1548
1979
  const { pendingConflicts: _discarded,...rest } = state;
1549
1980
  return {
1550
1981
  state: {
@@ -1573,7 +2004,6 @@ function transition(state, event) {
1573
2004
  effects
1574
2005
  };
1575
2006
  }
1576
- effects.push(log("info", `Local change detected: ${relativePath}`));
1577
2007
  effects.push({
1578
2008
  type: "SEND_LOCAL_CHANGE",
1579
2009
  fileName: relativePath,
@@ -1582,7 +2012,7 @@ function transition(state, event) {
1582
2012
  break;
1583
2013
  }
1584
2014
  case "delete": {
1585
- effects.push(log("info", `Local delete detected: ${relativePath}`), {
2015
+ effects.push(log("debug", `Local delete detected: ${relativePath}`), {
1586
2016
  type: "REQUEST_LOCAL_DELETE_DECISION",
1587
2017
  fileName: relativePath,
1588
2018
  requireConfirmation: true
@@ -1605,23 +2035,37 @@ function transition(state, event) {
1605
2035
  }
1606
2036
  const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = autoResolveConflicts(state.pendingConflicts, event.versions);
1607
2037
  if (autoResolvedLocal.length > 0) {
1608
- effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedLocal.length} local changes`));
1609
- 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({
1610
2047
  type: "SEND_LOCAL_CHANGE",
1611
2048
  fileName: conflict.fileName,
1612
2049
  content: conflict.localContent
1613
2050
  });
1614
2051
  }
1615
- if (autoResolvedRemote.length > 0) effects.push(log("info", `[AUTO-RESOLVE] Applying ${autoResolvedRemote.length} remote changes`), {
1616
- type: "WRITE_FILES",
1617
- files: autoResolvedRemote.map((conflict) => ({
1618
- name: conflict.fileName,
1619
- content: conflict.remoteContent,
1620
- modifiedAt: conflict.remoteModifiedAt ?? Date.now()
1621
- }))
1622
- });
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
+ }
1623
2067
  if (remainingConflicts.length > 0) {
1624
- effects.push(log("warn", `[AUTO-RESOLVE] ${remainingConflicts.length} conflicts require user resolution`), {
2068
+ effects.push(log("warn", `${pluralize(remainingConflicts.length, "conflict")} require resolution`), {
1625
2069
  type: "REQUEST_CONFLICT_DECISIONS",
1626
2070
  conflicts: remainingConflicts
1627
2071
  });
@@ -1633,7 +2077,13 @@ function transition(state, event) {
1633
2077
  effects
1634
2078
  };
1635
2079
  }
1636
- 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
+ });
1637
2087
  const { pendingConflicts: _discarded,...rest } = state;
1638
2088
  return {
1639
2089
  state: {
@@ -1665,7 +2115,7 @@ async function executeEffect(effect, context) {
1665
2115
  const projectName = config.explicitName ?? effect.projectInfo.projectName;
1666
2116
  config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir);
1667
2117
  config.filesDir = `${config.projectDir}/files`;
1668
- info(`Files directory: ${config.filesDir}`);
2118
+ debug(`Files directory: ${config.filesDir}`);
1669
2119
  await fs.mkdir(config.filesDir, { recursive: true });
1670
2120
  }
1671
2121
  return [];
@@ -1673,7 +2123,7 @@ async function executeEffect(effect, context) {
1673
2123
  case "LOAD_PERSISTED_STATE": {
1674
2124
  if (config.projectDir) {
1675
2125
  await fileMetadataCache.initialize(config.projectDir);
1676
- info(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
2126
+ debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`);
1677
2127
  }
1678
2128
  return [];
1679
2129
  }
@@ -1697,13 +2147,17 @@ async function executeEffect(effect, context) {
1697
2147
  }];
1698
2148
  }
1699
2149
  case "SEND_MESSAGE": {
1700
- if (syncState.socket) await sendMessage(syncState.socket, effect.payload);
2150
+ if (syncState.socket) {
2151
+ const sent = await sendMessage(syncState.socket, effect.payload);
2152
+ if (!sent) warn(`Failed to send message: ${effect.payload.type}`);
2153
+ } else warn(`No socket available to send: ${effect.payload.type}`);
1701
2154
  return [];
1702
2155
  }
1703
2156
  case "WRITE_FILES": {
1704
2157
  if (config.filesDir) {
1705
2158
  await writeRemoteFiles(effect.files, config.filesDir, hashTracker, installer ?? void 0);
1706
2159
  for (const file of effect.files) {
2160
+ if (!effect.silent) fileDown(file.name);
1707
2161
  const remoteTimestamp = file.modifiedAt ?? Date.now();
1708
2162
  fileMetadataCache.recordRemoteWrite(file.name, file.content, remoteTimestamp);
1709
2163
  }
@@ -1713,6 +2167,7 @@ async function executeEffect(effect, context) {
1713
2167
  case "DELETE_LOCAL_FILES": {
1714
2168
  if (config.filesDir) for (const fileName of effect.names) {
1715
2169
  await deleteLocalFile(fileName, config.filesDir, hashTracker);
2170
+ fileDelete(fileName);
1716
2171
  fileMetadataCache.recordDelete(fileName);
1717
2172
  }
1718
2173
  return [];
@@ -1734,7 +2189,7 @@ async function executeEffect(effect, context) {
1734
2189
  lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp
1735
2190
  };
1736
2191
  });
1737
- info(`[CONFLICTS] Requesting remote version data for ${versionRequests.length} file(s)`);
2192
+ debug(`Requesting remote version data for ${pluralize(versionRequests.length, "file")}`);
1738
2193
  await sendMessage(syncState.socket, {
1739
2194
  type: "conflict-version-request",
1740
2195
  conflicts: versionRequests
@@ -1759,17 +2214,27 @@ async function executeEffect(effect, context) {
1759
2214
  return [];
1760
2215
  }
1761
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
+ }
1762
2223
  if (hashTracker.shouldSkip(effect.fileName, effect.content)) return [];
2224
+ debug(`Local change detected: ${effect.fileName}`);
1763
2225
  try {
1764
- if (syncState.socket) await sendMessage(syncState.socket, {
1765
- type: "file-change",
1766
- fileName: effect.fileName,
1767
- content: effect.content
1768
- });
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
+ }
1769
2234
  hashTracker.remember(effect.fileName, effect.content);
1770
2235
  if (installer) installer.process(effect.fileName, effect.content);
1771
2236
  } catch (err) {
1772
- console.warn(`Failed to push change for ${effect.fileName}, will re-sync on next diff:`, err);
2237
+ warn(`Failed to push ${effect.fileName}`);
1773
2238
  }
1774
2239
  return [];
1775
2240
  }
@@ -1801,6 +2266,20 @@ async function executeEffect(effect, context) {
1801
2266
  await fileMetadataCache.flush();
1802
2267
  return [];
1803
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
+ }
1804
2283
  case "LOG": {
1805
2284
  const logFn = effect.level === "info" ? info : effect.level === "warn" ? warn : debug;
1806
2285
  logFn(effect.message);
@@ -1812,9 +2291,7 @@ async function executeEffect(effect, context) {
1812
2291
  * Starts the sync controller with the given configuration
1813
2292
  */
1814
2293
  async function start(config) {
1815
- info("🚀 Starting Code Link");
1816
- info(`Project: ${config.projectHash}`);
1817
- info(`Port: ${config.port} (auto-selected from project hash)`);
2294
+ status("Waiting for Plugin connection...");
1818
2295
  const hashTracker = createHashTracker();
1819
2296
  const fileMetadataCache = new FileMetadataCache();
1820
2297
  let installer = null;
@@ -1827,9 +2304,14 @@ async function start(config) {
1827
2304
  };
1828
2305
  const userActions = new UserActionCoordinator();
1829
2306
  async function processEvent(event) {
2307
+ const socketState = syncState.socket?.readyState;
2308
+ debug(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
1830
2309
  const result = transition(syncState, event);
1831
2310
  syncState = result.state;
2311
+ if (result.effects.length > 0) debug(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
1832
2312
  for (const effect of result.effects) {
2313
+ const currentSocketState = syncState.socket?.readyState;
2314
+ if (currentSocketState !== void 0 && currentSocketState !== 1) debug(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
1833
2315
  const followUpEvents = await executeEffect(effect, {
1834
2316
  config,
1835
2317
  hashTracker,
@@ -1841,12 +2323,13 @@ async function start(config) {
1841
2323
  for (const followUpEvent of followUpEvents) await processEvent(followUpEvent);
1842
2324
  }
1843
2325
  }
1844
- const connection = initConnection(config.port);
2326
+ const connection = await initConnection(config.port);
1845
2327
  connection.on("handshake", async (client, message) => {
1846
- info("Received handshake from plugin");
1847
- info(`Project: ${message.projectName} (${message.projectId})`);
1848
- if (message.projectId !== config.projectHash) {
1849
- 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}`);
1850
2333
  client.close();
1851
2334
  return;
1852
2335
  }
@@ -1863,7 +2346,9 @@ async function start(config) {
1863
2346
  await installer.initialize();
1864
2347
  startWatcher();
1865
2348
  }
1866
- success("Handshake successful - connection established");
2349
+ cancelDisconnectMessage();
2350
+ const wasDisconnected = wasRecentlyDisconnected();
2351
+ if (!wasDisconnected && !didShowDisconnect()) success(`Connected to ${message.projectName}`);
1867
2352
  });
1868
2353
  async function handleMessage(message) {
1869
2354
  if (!config.projectDir || !installer) {
@@ -1875,12 +2360,15 @@ async function start(config) {
1875
2360
  case "request-files":
1876
2361
  event = { type: "REQUEST_FILES" };
1877
2362
  break;
1878
- case "file-list":
2363
+ case "file-list": {
2364
+ const totalSize = message.files.reduce((sum, f) => sum + (f.content?.length ?? 0), 0);
2365
+ debug(`Received file list: ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB)`);
1879
2366
  event = {
1880
2367
  type: "FILE_LIST",
1881
2368
  files: message.files
1882
2369
  };
1883
2370
  break;
2371
+ }
1884
2372
  case "file-change":
1885
2373
  event = {
1886
2374
  type: "FILE_CHANGE",
@@ -1955,10 +2443,11 @@ async function start(config) {
1955
2443
  }
1956
2444
  });
1957
2445
  connection.on("disconnect", async () => {
1958
- warn("Plugin disconnected");
2446
+ scheduleDisconnectMessage(() => {
2447
+ status("Disconnected, waiting to reconnect...");
2448
+ });
1959
2449
  await processEvent({ type: "DISCONNECT" });
1960
2450
  userActions.cleanup();
1961
- info("Will perform full diff on reconnect");
1962
2451
  });
1963
2452
  connection.on("error", (err) => {
1964
2453
  error("Error on WebSocket connection:", err);
@@ -1974,10 +2463,9 @@ async function start(config) {
1974
2463
  });
1975
2464
  });
1976
2465
  };
1977
- info("✓ Controller initialized and ready");
1978
- info(`Waiting for plugin connection on port ${config.port}...`);
1979
2466
  process.on("SIGINT", async () => {
1980
- info("\nShutting down gracefully...");
2467
+ console.log();
2468
+ status("Shutting down...");
1981
2469
  if (watcher) await watcher.close();
1982
2470
  connection.close();
1983
2471
  process.exit(0);
@@ -1994,7 +2482,16 @@ program.exitOverride((err) => {
1994
2482
  }
1995
2483
  throw err;
1996
2484
  });
1997
- program.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("<projectHash>", "Framer Project ID Hash").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").action(async (projectHash, options) => {
2485
+ program.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("[projectHash]", "Framer Project ID Hash (auto-detected from package.json if omitted)").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").action(async (projectHash, options) => {
2486
+ if (!projectHash) {
2487
+ const detected = await getProjectHashFromCwd();
2488
+ if (detected) projectHash = detected;
2489
+ else {
2490
+ console.error("No project ID provided and no framerProjectId found in package.json.");
2491
+ console.error("Either run this command from a project directory or copy the command from the Code Link Plugin.");
2492
+ process.exit(1);
2493
+ }
2494
+ }
1998
2495
  const isDev = process.env.NODE_ENV === "development";
1999
2496
  if (options.logLevel) {
2000
2497
  const levelMap = {
@@ -2007,6 +2504,7 @@ program.name("code-link").description("Sync Framer code components to your local
2007
2504
  if (level !== void 0) setLogLevel(level);
2008
2505
  } else if (options.verbose || isDev) setLogLevel(LogLevel.DEBUG);
2009
2506
  const port = getPortFromHash(projectHash);
2507
+ banner("0.1.3", port);
2010
2508
  const config = {
2011
2509
  port,
2012
2510
  projectHash,
@@ -2016,8 +2514,12 @@ program.name("code-link").description("Sync Framer code components to your local
2016
2514
  explicitDir: options.dir,
2017
2515
  explicitName: options.name
2018
2516
  };
2019
- if (config.dangerouslyAutoDelete) info("⚠️ Auto-delete mode enabled - files will be deleted without confirmation");
2020
- await start(config);
2517
+ if (config.dangerouslyAutoDelete) warn("Auto-delete mode enabled - files will be deleted without confirmation");
2518
+ try {
2519
+ await start(config);
2520
+ } catch (err) {
2521
+ process.exit(1);
2522
+ }
2021
2523
  });
2022
2524
  program.parse();
2023
2525