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