framer-code-link 0.1.1 → 0.1.3
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 +134 -39
- package/package.json +1 -1
- package/src/controller.ts +35 -3
- package/src/helpers/connection.ts +124 -46
- package/src/index.ts +28 -3
- package/src/utils/project.ts +21 -2
package/dist/index.js
CHANGED
|
@@ -44,50 +44,107 @@ function success(message, ...args) {
|
|
|
44
44
|
//#region src/helpers/connection.ts
|
|
45
45
|
/**
|
|
46
46
|
* Initializes a WebSocket server and returns a connection interface
|
|
47
|
+
* Returns a Promise that resolves when the server is ready, or rejects on startup errors
|
|
47
48
|
*/
|
|
48
49
|
function initConnection(port) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const wss = new WebSocketServer({ port });
|
|
52
|
+
const handlers = {};
|
|
53
|
+
let connectionId = 0;
|
|
54
|
+
let isReady = false;
|
|
55
|
+
wss.on("error", (err) => {
|
|
56
|
+
if (!isReady) {
|
|
57
|
+
if (err.code === "EADDRINUSE") {
|
|
58
|
+
error(`Port ${port} is already in use.`);
|
|
59
|
+
error(`This usually means another instance of Code Link is already running.`);
|
|
60
|
+
error(``);
|
|
61
|
+
error(`To fix this:`);
|
|
62
|
+
error(` 1. Close any other terminal running Code Link for this project`);
|
|
63
|
+
error(` 2. Or run: lsof -i :${port} | grep LISTEN`);
|
|
64
|
+
error(` Then kill the process: kill -9 <PID>`);
|
|
65
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
66
|
+
} else {
|
|
67
|
+
error(`Failed to start WebSocket server: ${err.message}`);
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
61
71
|
}
|
|
72
|
+
error(`WebSocket server error: ${err.message}`);
|
|
62
73
|
});
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
wss.on("listening", () => {
|
|
75
|
+
isReady = true;
|
|
76
|
+
info(`WebSocket server listening on port ${port}`);
|
|
77
|
+
wss.on("connection", (ws) => {
|
|
78
|
+
const connId = ++connectionId;
|
|
79
|
+
info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`);
|
|
80
|
+
ws.on("message", (data) => {
|
|
81
|
+
try {
|
|
82
|
+
const message = JSON.parse(data.toString());
|
|
83
|
+
if (message.type === "handshake") {
|
|
84
|
+
info(`[CONN ${connId}] Received handshake`);
|
|
85
|
+
handlers.onHandshake?.(ws, message);
|
|
86
|
+
} else handlers.onMessage?.(message);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
error(`[CONN ${connId}] Failed to parse message:`, err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
ws.on("close", (code, reason) => {
|
|
92
|
+
info(`[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`);
|
|
93
|
+
handlers.onDisconnect?.();
|
|
94
|
+
});
|
|
95
|
+
ws.on("error", (err) => {
|
|
96
|
+
error(`[CONN ${connId}] WebSocket error:`, err);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
resolve({
|
|
100
|
+
on(event, handler) {
|
|
101
|
+
if (event === "handshake") handlers.onHandshake = handler;
|
|
102
|
+
else if (event === "message") handlers.onMessage = handler;
|
|
103
|
+
else if (event === "disconnect") handlers.onDisconnect = handler;
|
|
104
|
+
else if (event === "error") handlers.onError = handler;
|
|
105
|
+
},
|
|
106
|
+
close() {
|
|
107
|
+
wss.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
69
110
|
});
|
|
70
111
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* WebSocket readyState constants for reference
|
|
115
|
+
*/
|
|
116
|
+
const READY_STATE = {
|
|
117
|
+
CONNECTING: 0,
|
|
118
|
+
OPEN: 1,
|
|
119
|
+
CLOSING: 2,
|
|
120
|
+
CLOSED: 3
|
|
121
|
+
};
|
|
122
|
+
function readyStateToString(state) {
|
|
123
|
+
switch (state) {
|
|
124
|
+
case 0: return "CONNECTING";
|
|
125
|
+
case 1: return "OPEN";
|
|
126
|
+
case 2: return "CLOSING";
|
|
127
|
+
case 3: return "CLOSED";
|
|
128
|
+
default: return `UNKNOWN(${state})`;
|
|
129
|
+
}
|
|
82
130
|
}
|
|
83
131
|
/**
|
|
84
132
|
* Sends a message to a connected socket
|
|
133
|
+
* Returns false if the socket is not open (instead of throwing)
|
|
85
134
|
*/
|
|
86
135
|
function sendMessage(socket, message) {
|
|
87
|
-
return new Promise((resolve
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
if (socket.readyState !== READY_STATE.OPEN) {
|
|
138
|
+
const stateStr = readyStateToString(socket.readyState);
|
|
139
|
+
info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`);
|
|
140
|
+
resolve(false);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
88
143
|
socket.send(JSON.stringify(message), (err) => {
|
|
89
|
-
if (err)
|
|
90
|
-
|
|
144
|
+
if (err) {
|
|
145
|
+
error(`[WS] Send error for ${message.type}:`, err.message);
|
|
146
|
+
resolve(false);
|
|
147
|
+
} else resolve(true);
|
|
91
148
|
});
|
|
92
149
|
});
|
|
93
150
|
}
|
|
@@ -1243,6 +1300,19 @@ function validateIncomingChange(file, fileMeta, currentMode) {
|
|
|
1243
1300
|
function toPackageName(name) {
|
|
1244
1301
|
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1245
1302
|
}
|
|
1303
|
+
function toDirName(name) {
|
|
1304
|
+
return name.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
1305
|
+
}
|
|
1306
|
+
async function getProjectHashFromCwd() {
|
|
1307
|
+
try {
|
|
1308
|
+
const packageJsonPath = path.join(process.cwd(), "package.json");
|
|
1309
|
+
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
1310
|
+
const pkg = JSON.parse(content);
|
|
1311
|
+
return pkg.framerProjectId ?? null;
|
|
1312
|
+
} catch {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1246
1316
|
async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
|
|
1247
1317
|
if (explicitDir) {
|
|
1248
1318
|
const resolved = path.resolve(explicitDir);
|
|
@@ -1253,11 +1323,12 @@ async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
|
|
|
1253
1323
|
const existing = await findExistingProjectDir(cwd, projectHash);
|
|
1254
1324
|
if (existing) return existing;
|
|
1255
1325
|
if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
|
|
1256
|
-
const dirName =
|
|
1326
|
+
const dirName = toDirName(projectName);
|
|
1327
|
+
const pkgName = toPackageName(projectName);
|
|
1257
1328
|
const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
|
|
1258
1329
|
await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
|
|
1259
1330
|
const pkg = {
|
|
1260
|
-
name:
|
|
1331
|
+
name: pkgName || projectHash,
|
|
1261
1332
|
version: "1.0.0",
|
|
1262
1333
|
private: true,
|
|
1263
1334
|
framerProjectId: projectHash,
|
|
@@ -1697,7 +1768,10 @@ async function executeEffect(effect, context) {
|
|
|
1697
1768
|
}];
|
|
1698
1769
|
}
|
|
1699
1770
|
case "SEND_MESSAGE": {
|
|
1700
|
-
if (syncState.socket)
|
|
1771
|
+
if (syncState.socket) {
|
|
1772
|
+
const sent = await sendMessage(syncState.socket, effect.payload);
|
|
1773
|
+
if (!sent) warn(`Failed to send message: ${effect.payload.type}`);
|
|
1774
|
+
} else warn(`No socket available to send: ${effect.payload.type}`);
|
|
1701
1775
|
return [];
|
|
1702
1776
|
}
|
|
1703
1777
|
case "WRITE_FILES": {
|
|
@@ -1827,9 +1901,14 @@ async function start(config) {
|
|
|
1827
1901
|
};
|
|
1828
1902
|
const userActions = new UserActionCoordinator();
|
|
1829
1903
|
async function processEvent(event) {
|
|
1904
|
+
const socketState = syncState.socket?.readyState;
|
|
1905
|
+
info(`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`);
|
|
1830
1906
|
const result = transition(syncState, event);
|
|
1831
1907
|
syncState = result.state;
|
|
1908
|
+
if (result.effects.length > 0) info(`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`);
|
|
1832
1909
|
for (const effect of result.effects) {
|
|
1910
|
+
const currentSocketState = syncState.socket?.readyState;
|
|
1911
|
+
if (currentSocketState !== void 0 && currentSocketState !== 1) warn(`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`);
|
|
1833
1912
|
const followUpEvents = await executeEffect(effect, {
|
|
1834
1913
|
config,
|
|
1835
1914
|
hashTracker,
|
|
@@ -1841,7 +1920,7 @@ async function start(config) {
|
|
|
1841
1920
|
for (const followUpEvent of followUpEvents) await processEvent(followUpEvent);
|
|
1842
1921
|
}
|
|
1843
1922
|
}
|
|
1844
|
-
const connection = initConnection(config.port);
|
|
1923
|
+
const connection = await initConnection(config.port);
|
|
1845
1924
|
connection.on("handshake", async (client, message) => {
|
|
1846
1925
|
info("Received handshake from plugin");
|
|
1847
1926
|
info(`Project: ${message.projectName} (${message.projectId})`);
|
|
@@ -1875,12 +1954,15 @@ async function start(config) {
|
|
|
1875
1954
|
case "request-files":
|
|
1876
1955
|
event = { type: "REQUEST_FILES" };
|
|
1877
1956
|
break;
|
|
1878
|
-
case "file-list":
|
|
1957
|
+
case "file-list": {
|
|
1958
|
+
const totalSize = message.files.reduce((sum, f) => sum + (f.content?.length ?? 0), 0);
|
|
1959
|
+
info(`[FILE_LIST] Received ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB total)`);
|
|
1879
1960
|
event = {
|
|
1880
1961
|
type: "FILE_LIST",
|
|
1881
1962
|
files: message.files
|
|
1882
1963
|
};
|
|
1883
1964
|
break;
|
|
1965
|
+
}
|
|
1884
1966
|
case "file-change":
|
|
1885
1967
|
event = {
|
|
1886
1968
|
type: "FILE_CHANGE",
|
|
@@ -1994,7 +2076,16 @@ program.exitOverride((err) => {
|
|
|
1994
2076
|
}
|
|
1995
2077
|
throw err;
|
|
1996
2078
|
});
|
|
1997
|
-
program.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("
|
|
2079
|
+
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) => {
|
|
2080
|
+
if (!projectHash) {
|
|
2081
|
+
const detected = await getProjectHashFromCwd();
|
|
2082
|
+
if (detected) projectHash = detected;
|
|
2083
|
+
else {
|
|
2084
|
+
console.error("No project ID provided and no framerProjectId found in package.json.");
|
|
2085
|
+
console.error("Either run this command from a project directory or copy the command from the Code Link Plugin.");
|
|
2086
|
+
process.exit(1);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
1998
2089
|
const isDev = process.env.NODE_ENV === "development";
|
|
1999
2090
|
if (options.logLevel) {
|
|
2000
2091
|
const levelMap = {
|
|
@@ -2017,7 +2108,11 @@ program.name("code-link").description("Sync Framer code components to your local
|
|
|
2017
2108
|
explicitName: options.name
|
|
2018
2109
|
};
|
|
2019
2110
|
if (config.dangerouslyAutoDelete) info("⚠️ Auto-delete mode enabled - files will be deleted without confirmation");
|
|
2020
|
-
|
|
2111
|
+
try {
|
|
2112
|
+
await start(config);
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
process.exit(1);
|
|
2115
|
+
}
|
|
2021
2116
|
});
|
|
2022
2117
|
program.parse();
|
|
2023
2118
|
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -760,7 +760,12 @@ async function executeEffect(
|
|
|
760
760
|
|
|
761
761
|
case "SEND_MESSAGE": {
|
|
762
762
|
if (syncState.socket) {
|
|
763
|
-
await sendMessage(syncState.socket, effect.payload)
|
|
763
|
+
const sent = await sendMessage(syncState.socket, effect.payload)
|
|
764
|
+
if (!sent) {
|
|
765
|
+
warn(`Failed to send message: ${effect.payload.type}`)
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
warn(`No socket available to send: ${effect.payload.type}`)
|
|
764
769
|
}
|
|
765
770
|
return []
|
|
766
771
|
}
|
|
@@ -976,11 +981,30 @@ export async function start(config: Config): Promise<void> {
|
|
|
976
981
|
// State Machine Execution Helper
|
|
977
982
|
// Process events through state machine and execute effects recursively
|
|
978
983
|
async function processEvent(event: SyncEvent) {
|
|
984
|
+
const socketState = syncState.socket?.readyState
|
|
985
|
+
info(
|
|
986
|
+
`[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})`
|
|
987
|
+
)
|
|
988
|
+
|
|
979
989
|
const result = transition(syncState, event)
|
|
980
990
|
syncState = result.state
|
|
981
991
|
|
|
992
|
+
if (result.effects.length > 0) {
|
|
993
|
+
info(
|
|
994
|
+
`[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}`
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
982
998
|
// Execute all effects and process any follow-up events
|
|
983
999
|
for (const effect of result.effects) {
|
|
1000
|
+
// Check socket state before each effect
|
|
1001
|
+
const currentSocketState = syncState.socket?.readyState
|
|
1002
|
+
if (currentSocketState !== undefined && currentSocketState !== 1) {
|
|
1003
|
+
warn(
|
|
1004
|
+
`[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}`
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
984
1008
|
const followUpEvents = await executeEffect(effect, {
|
|
985
1009
|
config,
|
|
986
1010
|
hashTracker,
|
|
@@ -998,7 +1022,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
998
1022
|
}
|
|
999
1023
|
|
|
1000
1024
|
// WebSocket Connection
|
|
1001
|
-
const connection = initConnection(config.port)
|
|
1025
|
+
const connection = await initConnection(config.port)
|
|
1002
1026
|
|
|
1003
1027
|
// Handle initial handshake
|
|
1004
1028
|
connection.on("handshake", async (client: WebSocket, message) => {
|
|
@@ -1051,9 +1075,17 @@ export async function start(config: Config): Promise<void> {
|
|
|
1051
1075
|
event = { type: "REQUEST_FILES" }
|
|
1052
1076
|
break
|
|
1053
1077
|
|
|
1054
|
-
case "file-list":
|
|
1078
|
+
case "file-list": {
|
|
1079
|
+
const totalSize = message.files.reduce(
|
|
1080
|
+
(sum, f) => sum + (f.content?.length ?? 0),
|
|
1081
|
+
0
|
|
1082
|
+
)
|
|
1083
|
+
info(
|
|
1084
|
+
`[FILE_LIST] Received ${message.files.length} files (${(totalSize / 1024).toFixed(1)}KB total)`
|
|
1085
|
+
)
|
|
1055
1086
|
event = { type: "FILE_LIST", files: message.files }
|
|
1056
1087
|
break
|
|
1088
|
+
}
|
|
1057
1089
|
|
|
1058
1090
|
case "file-change":
|
|
1059
1091
|
event = {
|
|
@@ -29,74 +29,152 @@ export interface Connection {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Initializes a WebSocket server and returns a connection interface
|
|
32
|
+
* Returns a Promise that resolves when the server is ready, or rejects on startup errors
|
|
32
33
|
*/
|
|
33
|
-
export function initConnection(port: number): Connection {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
wss.on("connection", (ws: WebSocket) => {
|
|
40
|
-
info("Client connected")
|
|
41
|
-
|
|
42
|
-
ws.on("message", (data: Buffer) => {
|
|
43
|
-
try {
|
|
44
|
-
const message = JSON.parse(data.toString()) as IncomingMessage
|
|
34
|
+
export function initConnection(port: number): Promise<Connection> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const wss = new WebSocketServer({ port })
|
|
37
|
+
const handlers: Partial<ConnectionCallbacks> = {}
|
|
38
|
+
let connectionId = 0
|
|
39
|
+
let isReady = false
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
// Handle server-level errors (e.g., EADDRINUSE)
|
|
42
|
+
wss.on("error", (err: NodeJS.ErrnoException) => {
|
|
43
|
+
if (!isReady) {
|
|
44
|
+
// Startup error - reject the promise with a helpful message
|
|
45
|
+
if (err.code === "EADDRINUSE") {
|
|
46
|
+
error(`Port ${port} is already in use.`)
|
|
47
|
+
error(
|
|
48
|
+
`This usually means another instance of Code Link is already running.`
|
|
49
|
+
)
|
|
50
|
+
error(``)
|
|
51
|
+
error(`To fix this:`)
|
|
52
|
+
error(
|
|
53
|
+
` 1. Close any other terminal running Code Link for this project`
|
|
54
|
+
)
|
|
55
|
+
error(` 2. Or run: lsof -i :${port} | grep LISTEN`)
|
|
56
|
+
error(` Then kill the process: kill -9 <PID>`)
|
|
57
|
+
reject(new Error(`Port ${port} is already in use`))
|
|
49
58
|
} else {
|
|
50
|
-
|
|
59
|
+
error(`Failed to start WebSocket server: ${err.message}`)
|
|
60
|
+
reject(err)
|
|
51
61
|
}
|
|
52
|
-
|
|
53
|
-
error("Failed to parse message:", err)
|
|
62
|
+
return
|
|
54
63
|
}
|
|
64
|
+
// Runtime error - log but don't crash
|
|
65
|
+
error(`WebSocket server error: ${err.message}`)
|
|
55
66
|
})
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
// Server is ready when it starts listening
|
|
69
|
+
wss.on("listening", () => {
|
|
70
|
+
isReady = true
|
|
71
|
+
info(`WebSocket server listening on port ${port}`)
|
|
72
|
+
|
|
73
|
+
wss.on("connection", (ws: WebSocket) => {
|
|
74
|
+
const connId = ++connectionId
|
|
75
|
+
info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`)
|
|
76
|
+
|
|
77
|
+
ws.on("message", (data: Buffer) => {
|
|
78
|
+
try {
|
|
79
|
+
const message = JSON.parse(data.toString()) as IncomingMessage
|
|
80
|
+
|
|
81
|
+
// Special handling for handshake
|
|
82
|
+
if (message.type === "handshake") {
|
|
83
|
+
info(`[CONN ${connId}] Received handshake`)
|
|
84
|
+
handlers.onHandshake?.(ws, message)
|
|
85
|
+
} else {
|
|
86
|
+
handlers.onMessage?.(message)
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
error(`[CONN ${connId}] Failed to parse message:`, err)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
ws.on("close", (code, reason) => {
|
|
94
|
+
info(
|
|
95
|
+
`[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
|
|
96
|
+
)
|
|
97
|
+
handlers.onDisconnect?.()
|
|
98
|
+
})
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
ws.on("error", (err) => {
|
|
101
|
+
error(`[CONN ${connId}] WebSocket error:`, err)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
resolve({
|
|
106
|
+
on(
|
|
107
|
+
event: "handshake" | "message" | "disconnect" | "error",
|
|
108
|
+
handler: any
|
|
109
|
+
): void {
|
|
110
|
+
if (event === "handshake") {
|
|
111
|
+
handlers.onHandshake = handler
|
|
112
|
+
} else if (event === "message") {
|
|
113
|
+
handlers.onMessage = handler
|
|
114
|
+
} else if (event === "disconnect") {
|
|
115
|
+
handlers.onDisconnect = handler
|
|
116
|
+
} else if (event === "error") {
|
|
117
|
+
handlers.onError = handler
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
close(): void {
|
|
122
|
+
wss.close()
|
|
123
|
+
},
|
|
124
|
+
})
|
|
64
125
|
})
|
|
65
126
|
})
|
|
127
|
+
}
|
|
66
128
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} else if (event === "disconnect") {
|
|
77
|
-
handlers.onDisconnect = handler
|
|
78
|
-
} else if (event === "error") {
|
|
79
|
-
handlers.onError = handler
|
|
80
|
-
}
|
|
81
|
-
},
|
|
129
|
+
/**
|
|
130
|
+
* WebSocket readyState constants for reference
|
|
131
|
+
*/
|
|
132
|
+
const READY_STATE = {
|
|
133
|
+
CONNECTING: 0,
|
|
134
|
+
OPEN: 1,
|
|
135
|
+
CLOSING: 2,
|
|
136
|
+
CLOSED: 3,
|
|
137
|
+
} as const
|
|
82
138
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
139
|
+
function readyStateToString(state: number): string {
|
|
140
|
+
switch (state) {
|
|
141
|
+
case 0:
|
|
142
|
+
return "CONNECTING"
|
|
143
|
+
case 1:
|
|
144
|
+
return "OPEN"
|
|
145
|
+
case 2:
|
|
146
|
+
return "CLOSING"
|
|
147
|
+
case 3:
|
|
148
|
+
return "CLOSED"
|
|
149
|
+
default:
|
|
150
|
+
return `UNKNOWN(${state})`
|
|
86
151
|
}
|
|
87
152
|
}
|
|
88
153
|
|
|
89
154
|
/**
|
|
90
155
|
* Sends a message to a connected socket
|
|
156
|
+
* Returns false if the socket is not open (instead of throwing)
|
|
91
157
|
*/
|
|
92
158
|
export function sendMessage(
|
|
93
159
|
socket: WebSocket,
|
|
94
160
|
message: OutgoingMessage
|
|
95
|
-
): Promise<
|
|
96
|
-
return new Promise((resolve
|
|
161
|
+
): Promise<boolean> {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
// Check socket state before attempting to send
|
|
164
|
+
if (socket.readyState !== READY_STATE.OPEN) {
|
|
165
|
+
const stateStr = readyStateToString(socket.readyState)
|
|
166
|
+
info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`)
|
|
167
|
+
resolve(false)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
97
171
|
socket.send(JSON.stringify(message), (err) => {
|
|
98
|
-
if (err)
|
|
99
|
-
|
|
172
|
+
if (err) {
|
|
173
|
+
error(`[WS] Send error for ${message.type}:`, err.message)
|
|
174
|
+
resolve(false)
|
|
175
|
+
} else {
|
|
176
|
+
resolve(true)
|
|
177
|
+
}
|
|
100
178
|
})
|
|
101
179
|
})
|
|
102
180
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { start } from "./controller.js"
|
|
|
12
12
|
import type { Config } from "./types.js"
|
|
13
13
|
import { setLogLevel, LogLevel, info } from "./utils/logging.js"
|
|
14
14
|
import { getPortFromHash } from "./utils/hashing.js"
|
|
15
|
+
import { getProjectHashFromCwd } from "./utils/project.js"
|
|
15
16
|
|
|
16
17
|
const program = new Command()
|
|
17
18
|
|
|
@@ -27,7 +28,10 @@ program
|
|
|
27
28
|
.name("code-link")
|
|
28
29
|
.description("Sync Framer code components to your local filesystem")
|
|
29
30
|
.version("0.1.0")
|
|
30
|
-
.argument(
|
|
31
|
+
.argument(
|
|
32
|
+
"[projectHash]",
|
|
33
|
+
"Framer Project ID Hash (auto-detected from package.json if omitted)"
|
|
34
|
+
)
|
|
31
35
|
.option("-n, --name <name>", "Project name (optional)")
|
|
32
36
|
.option("-d, --dir <directory>", "Explicit project directory")
|
|
33
37
|
.option("-v, --verbose", "Enable verbose logging")
|
|
@@ -36,7 +40,23 @@ program
|
|
|
36
40
|
"--dangerously-auto-delete",
|
|
37
41
|
"Automatically delete remote files without confirmation"
|
|
38
42
|
)
|
|
39
|
-
.action(async (projectHash: string, options) => {
|
|
43
|
+
.action(async (projectHash: string | undefined, options) => {
|
|
44
|
+
// If no projectHash provided, try to read from cwd's package.json
|
|
45
|
+
if (!projectHash) {
|
|
46
|
+
const detected = await getProjectHashFromCwd()
|
|
47
|
+
if (detected) {
|
|
48
|
+
projectHash = detected
|
|
49
|
+
} else {
|
|
50
|
+
console.error(
|
|
51
|
+
"No project ID provided and no framerProjectId found in package.json."
|
|
52
|
+
)
|
|
53
|
+
console.error(
|
|
54
|
+
"Either run this command from a project directory or copy the command from the Code Link Plugin."
|
|
55
|
+
)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
// Auto-enable debug in development unless overridden
|
|
41
61
|
const isDev = process.env.NODE_ENV === "development"
|
|
42
62
|
|
|
@@ -73,7 +93,12 @@ program
|
|
|
73
93
|
)
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
try {
|
|
97
|
+
await start(config)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Error already logged, exit cleanly
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
77
102
|
})
|
|
78
103
|
|
|
79
104
|
program.parse()
|
package/src/utils/project.ts
CHANGED
|
@@ -17,6 +17,24 @@ export function toPackageName(name: string): string {
|
|
|
17
17
|
.replace(/-+/g, "-")
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function toDirName(name: string): string {
|
|
21
|
+
return name
|
|
22
|
+
.replace(/[^a-zA-Z0-9-]/g, "-")
|
|
23
|
+
.replace(/^-+|-+$/g, "")
|
|
24
|
+
.replace(/-+/g, "-")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getProjectHashFromCwd(): Promise<string | null> {
|
|
28
|
+
try {
|
|
29
|
+
const packageJsonPath = path.join(process.cwd(), "package.json")
|
|
30
|
+
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
31
|
+
const pkg = JSON.parse(content) as PackageJson
|
|
32
|
+
return pkg.framerProjectId ?? null
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export async function findOrCreateProjectDir(
|
|
21
39
|
projectHash: string,
|
|
22
40
|
projectName?: string,
|
|
@@ -40,12 +58,13 @@ export async function findOrCreateProjectDir(
|
|
|
40
58
|
)
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
const dirName =
|
|
61
|
+
const dirName = toDirName(projectName)
|
|
62
|
+
const pkgName = toPackageName(projectName)
|
|
44
63
|
const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6))
|
|
45
64
|
|
|
46
65
|
await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
|
|
47
66
|
const pkg: PackageJson = {
|
|
48
|
-
name:
|
|
67
|
+
name: pkgName || projectHash,
|
|
49
68
|
version: "1.0.0",
|
|
50
69
|
private: true,
|
|
51
70
|
framerProjectId: projectHash,
|