framer-code-link 0.1.0 → 0.1.2
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 +2 -195
- package/dist/index.js +16 -12
- package/package.json +1 -1
- package/src/controller.ts +44 -24
- package/src/helpers/connection.ts +55 -10
- package/src/index.ts +30 -2
- package/src/utils/project.ts +21 -2
package/README.md
CHANGED
|
@@ -1,196 +1,3 @@
|
|
|
1
|
-
# Framer Code Link CLI
|
|
1
|
+
# Framer Code Link CLI
|
|
2
2
|
|
|
3
|
-
A controller-centric architecture for syncing Framer code components to your local filesystem.
|
|
4
|
-
|
|
5
|
-
## Architecture
|
|
6
|
-
|
|
7
|
-
This implementation follows a **controller-centric design** inspired by Go's `main.go` pattern:
|
|
8
|
-
|
|
9
|
-
- **Single source of truth**: All runtime state lives in `controller.ts`
|
|
10
|
-
- **Clear orchestration**: The controller owns the lifecycle and message routing
|
|
11
|
-
- **Thin helpers**: Domain logic is delegated to pure/stateless helper functions
|
|
12
|
-
- **Extensible**: Easy to add new flows without touching the core controller logic
|
|
13
|
-
|
|
14
|
-
### File Structure
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
src/
|
|
18
|
-
controller.ts # The orchestration hub - holds state, routes messages
|
|
19
|
-
index.ts # CLI entry point
|
|
20
|
-
types.ts # Shared TypeScript types
|
|
21
|
-
helpers/
|
|
22
|
-
connection.ts # WebSocket server wrapper
|
|
23
|
-
watcher.ts # File watcher wrapper (chokidar)
|
|
24
|
-
files.ts # Disk I/O and conflict detection
|
|
25
|
-
installer.ts # Type installer (ATA wrapper)
|
|
26
|
-
utils/
|
|
27
|
-
hashing.ts # Hash tracking for echo prevention
|
|
28
|
-
sanitization.ts # Path sanitization
|
|
29
|
-
logging.ts # Consistent logging
|
|
30
|
-
paths.ts # Path manipulation utilities
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## Key Design Principles
|
|
34
|
-
|
|
35
|
-
### 1. Controller Owns State
|
|
36
|
-
|
|
37
|
-
The controller maintains all runtime state in plain objects:
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
const state: RuntimeState = {
|
|
41
|
-
socket: null, // Active WebSocket connection
|
|
42
|
-
initialSyncComplete: false, // Has first sync finished?
|
|
43
|
-
pendingConflicts: [], // Conflicts awaiting user input
|
|
44
|
-
lastRemoteAck: new Map(), // Remote timestamps for conflict detection
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### 2. Helpers Provide Data, Never Control
|
|
49
|
-
|
|
50
|
-
Helpers are thin wrappers that:
|
|
51
|
-
|
|
52
|
-
- Return data to the controller
|
|
53
|
-
- Never hold callbacks or "service" state
|
|
54
|
-
- Keep logic focused and testable
|
|
55
|
-
|
|
56
|
-
Example:
|
|
57
|
-
|
|
58
|
-
```typescript
|
|
59
|
-
// Helper provides data
|
|
60
|
-
const { conflicts, writes } = detectConflicts(remoteFiles, filesDir)
|
|
61
|
-
|
|
62
|
-
// Controller decides what to do
|
|
63
|
-
if (conflicts.length > 0) {
|
|
64
|
-
state.pendingConflicts = conflicts
|
|
65
|
-
sendMessage(socket, { type: "conflicts-detected", conflicts })
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### 3. Clear Message Routing
|
|
70
|
-
|
|
71
|
-
All incoming messages flow through a single `switch` statement in the controller:
|
|
72
|
-
|
|
73
|
-
```typescript
|
|
74
|
-
switch (message.type) {
|
|
75
|
-
case "request-files": {
|
|
76
|
-
/* ... */
|
|
77
|
-
}
|
|
78
|
-
case "file-list": {
|
|
79
|
-
/* ... */
|
|
80
|
-
}
|
|
81
|
-
case "file-change": {
|
|
82
|
-
/* ... */
|
|
83
|
-
}
|
|
84
|
-
// etc.
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
This makes the flow obvious and easy to trace.
|
|
89
|
-
|
|
90
|
-
### 4. Echo Prevention
|
|
91
|
-
|
|
92
|
-
Critical ordering prevents infinite loops:
|
|
93
|
-
|
|
94
|
-
```typescript
|
|
95
|
-
// 1. Update hash tracker FIRST (in memory)
|
|
96
|
-
hashTracker.remember(fileName, content)
|
|
97
|
-
|
|
98
|
-
// 2. Write to disk SECOND
|
|
99
|
-
await fs.writeFile(filePath, content)
|
|
100
|
-
|
|
101
|
-
// 3. Watcher fires, checks hash, and skips (no echo!)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
## Usage
|
|
105
|
-
|
|
106
|
-
### CLI
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
# Start with project hash (project name will be received from plugin during handshake)
|
|
110
|
-
npx framer-code-link <projectHash>
|
|
111
|
-
|
|
112
|
-
# Override project name (optional - useful if you want a different folder name)
|
|
113
|
-
npx framer-code-link <projectHash> --name "My Project"
|
|
114
|
-
|
|
115
|
-
# Custom directory
|
|
116
|
-
npx framer-code-link <projectHash> --dir ./my-project
|
|
117
|
-
|
|
118
|
-
# Verbose logging
|
|
119
|
-
npx framer-code-link <projectHash> --verbose
|
|
120
|
-
|
|
121
|
-
# Auto-delete files without confirmation (use with caution!)
|
|
122
|
-
npx framer-code-link <projectHash> --dangerously-auto-delete
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
**Note**: The project name is automatically received from the Framer plugin during the initial handshake. You only need to specify `--name` if you want to override it or if you're creating a new workspace before the plugin connects.
|
|
126
|
-
|
|
127
|
-
### Sync Behavior
|
|
128
|
-
|
|
129
|
-
The CLI performs **symmetric two-way sync** during initial connection:
|
|
130
|
-
|
|
131
|
-
- **Remote-only files** → Downloaded to local
|
|
132
|
-
- **Local-only files** → Uploaded to remote
|
|
133
|
-
- **Files on both sides with differences** → Conflict (requires user resolution)
|
|
134
|
-
|
|
135
|
-
This ensures that new files created while the CLI was offline are automatically synced in both directions.
|
|
136
|
-
|
|
137
|
-
### Delete Confirmation
|
|
138
|
-
|
|
139
|
-
By default, when you delete a file locally, the plugin will show a confirmation modal before deleting it from Framer. This prevents accidental data loss.
|
|
140
|
-
|
|
141
|
-
To skip confirmations and auto-delete files, use the `--dangerously-auto-delete` flag:
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
npx framer-code-link <projectHash> --dangerously-auto-delete
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**⚠️ Warning**: This will immediately delete files from Framer without any confirmation when you delete them locally.
|
|
148
|
-
|
|
149
|
-
### Programmatic
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
import { start } from "code-link-cli-next-2"
|
|
153
|
-
|
|
154
|
-
await start({
|
|
155
|
-
port: 8080,
|
|
156
|
-
projectHash: "my-project-hash",
|
|
157
|
-
projectDir: "/path/to/project",
|
|
158
|
-
filesDir: "/path/to/project/files",
|
|
159
|
-
})
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## Extending the Controller
|
|
163
|
-
|
|
164
|
-
To add new functionality:
|
|
165
|
-
|
|
166
|
-
1. **Add message type** to `types.ts`
|
|
167
|
-
2. **Add case** to the controller's `switch` statement
|
|
168
|
-
3. **Extract logic** to a helper if it's complex
|
|
169
|
-
4. **Update state** as needed in the controller
|
|
170
|
-
|
|
171
|
-
Example - adding a "bulk sync" feature:
|
|
172
|
-
|
|
173
|
-
```typescript
|
|
174
|
-
// 1. Add to types.ts
|
|
175
|
-
export type IncomingMessage =
|
|
176
|
-
| { type: "bulk-sync"; files: FileInfo[] }
|
|
177
|
-
| // ... existing types
|
|
178
|
-
|
|
179
|
-
// 2. Add case to controller
|
|
180
|
-
case "bulk-sync": {
|
|
181
|
-
info(`Bulk syncing ${message.files.length} files`)
|
|
182
|
-
await writeRemoteFiles(message.files, config.filesDir, hashTracker, installer)
|
|
183
|
-
success("Bulk sync complete")
|
|
184
|
-
break
|
|
185
|
-
}
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
## Testing Strategy
|
|
189
|
-
|
|
190
|
-
- **Controller**: Integration-style tests with mock connection + watcher
|
|
191
|
-
- **Helpers**: Unit tests for conflict detection, file operations, etc.
|
|
192
|
-
- **Utils**: Tiny focused tests for hashing, sanitization, etc.
|
|
193
|
-
|
|
194
|
-
## Design Document
|
|
195
|
-
|
|
196
|
-
For the full design rationale, see `../cli-next/design.md`.
|
|
3
|
+
A controller-centric architecture for two-way syncing Framer code components to your local filesystem.
|
package/dist/index.js
CHANGED
|
@@ -73,6 +73,7 @@ function initConnection(port) {
|
|
|
73
73
|
if (event === "handshake") handlers.onHandshake = handler;
|
|
74
74
|
else if (event === "message") handlers.onMessage = handler;
|
|
75
75
|
else if (event === "disconnect") handlers.onDisconnect = handler;
|
|
76
|
+
else if (event === "error") handlers.onError = handler;
|
|
76
77
|
},
|
|
77
78
|
close() {
|
|
78
79
|
wss.close();
|
|
@@ -1288,7 +1289,7 @@ async function matchesProject(packageJsonPath, projectHash) {
|
|
|
1288
1289
|
|
|
1289
1290
|
//#endregion
|
|
1290
1291
|
//#region src/controller.ts
|
|
1291
|
-
/**
|
|
1292
|
+
/** Log helper */
|
|
1292
1293
|
function log(level, message) {
|
|
1293
1294
|
return {
|
|
1294
1295
|
type: "LOG",
|
|
@@ -1562,13 +1563,6 @@ function transition(state, event) {
|
|
|
1562
1563
|
effects
|
|
1563
1564
|
};
|
|
1564
1565
|
}
|
|
1565
|
-
if (!state.socket) {
|
|
1566
|
-
effects.push(log("debug", `Ignoring watcher event (disconnected): ${kind} ${relativePath}`));
|
|
1567
|
-
return {
|
|
1568
|
-
state,
|
|
1569
|
-
effects
|
|
1570
|
-
};
|
|
1571
|
-
}
|
|
1572
1566
|
switch (kind) {
|
|
1573
1567
|
case "add":
|
|
1574
1568
|
case "change": {
|
|
@@ -1818,13 +1812,13 @@ async function executeEffect(effect, context) {
|
|
|
1818
1812
|
* Starts the sync controller with the given configuration
|
|
1819
1813
|
*/
|
|
1820
1814
|
async function start(config) {
|
|
1821
|
-
info("🚀 Starting
|
|
1815
|
+
info("🚀 Starting Code Link");
|
|
1822
1816
|
info(`Project: ${config.projectHash}`);
|
|
1823
1817
|
info(`Port: ${config.port} (auto-selected from project hash)`);
|
|
1824
1818
|
const hashTracker = createHashTracker();
|
|
1825
1819
|
const fileMetadataCache = new FileMetadataCache();
|
|
1826
1820
|
let installer = null;
|
|
1827
|
-
|
|
1821
|
+
let syncState = {
|
|
1828
1822
|
mode: "disconnected",
|
|
1829
1823
|
socket: null,
|
|
1830
1824
|
queuedDiffs: [],
|
|
@@ -1834,7 +1828,7 @@ async function start(config) {
|
|
|
1834
1828
|
const userActions = new UserActionCoordinator();
|
|
1835
1829
|
async function processEvent(event) {
|
|
1836
1830
|
const result = transition(syncState, event);
|
|
1837
|
-
|
|
1831
|
+
syncState = result.state;
|
|
1838
1832
|
for (const effect of result.effects) {
|
|
1839
1833
|
const followUpEvents = await executeEffect(effect, {
|
|
1840
1834
|
config,
|
|
@@ -1966,6 +1960,9 @@ async function start(config) {
|
|
|
1966
1960
|
userActions.cleanup();
|
|
1967
1961
|
info("Will perform full diff on reconnect");
|
|
1968
1962
|
});
|
|
1963
|
+
connection.on("error", (err) => {
|
|
1964
|
+
error("Error on WebSocket connection:", err);
|
|
1965
|
+
});
|
|
1969
1966
|
let watcher = null;
|
|
1970
1967
|
const startWatcher = () => {
|
|
1971
1968
|
if (!config.filesDir || watcher) return;
|
|
@@ -1990,7 +1987,14 @@ async function start(config) {
|
|
|
1990
1987
|
//#endregion
|
|
1991
1988
|
//#region src/index.ts
|
|
1992
1989
|
const program = new Command();
|
|
1993
|
-
program.
|
|
1990
|
+
program.exitOverride((err) => {
|
|
1991
|
+
if (err.code === "commander.missingArgument") {
|
|
1992
|
+
console.error("Missing Project ID. Copy command via Code Link Plugin.");
|
|
1993
|
+
process.exit(err.exitCode ?? 1);
|
|
1994
|
+
}
|
|
1995
|
+
throw err;
|
|
1996
|
+
});
|
|
1997
|
+
program.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("<projectHash>", "Framer Project ID Hash").option("-n, --name <name>", "Project name (optional)").option("-d, --dir <directory>", "Explicit project directory").option("-v, --verbose", "Enable verbose logging").option("--log-level <level>", "Set log level (debug, info, warn, error)").option("--dangerously-auto-delete", "Automatically delete remote files without confirmation").action(async (projectHash, options) => {
|
|
1994
1998
|
const isDev = process.env.NODE_ENV === "development";
|
|
1995
1999
|
if (options.logLevel) {
|
|
1996
2000
|
const levelMap = {
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Controller
|
|
3
3
|
* Single source of truth for all runtime state and orchestrates the sync lifecycle.
|
|
4
|
-
*
|
|
5
4
|
* Helpers are functions that provide data - they never hold control or callbacks.
|
|
6
5
|
*/
|
|
7
6
|
|
|
@@ -173,7 +172,7 @@ type Effect =
|
|
|
173
172
|
| { type: "PERSIST_STATE" }
|
|
174
173
|
| { type: "LOG"; level: "info" | "debug" | "warn"; message: string }
|
|
175
174
|
|
|
176
|
-
/**
|
|
175
|
+
/** Log helper */
|
|
177
176
|
function log(level: "info" | "debug" | "warn", message: string): Effect {
|
|
178
177
|
return { type: "LOG", level, message }
|
|
179
178
|
}
|
|
@@ -547,17 +546,6 @@ function transition(
|
|
|
547
546
|
return { state, effects }
|
|
548
547
|
}
|
|
549
548
|
|
|
550
|
-
// No socket - skip (will diff on reconnect)
|
|
551
|
-
if (!state.socket) {
|
|
552
|
-
effects.push(
|
|
553
|
-
log(
|
|
554
|
-
"debug",
|
|
555
|
-
`Ignoring watcher event (disconnected): ${kind} ${relativePath}`
|
|
556
|
-
)
|
|
557
|
-
)
|
|
558
|
-
return { state, effects }
|
|
559
|
-
}
|
|
560
|
-
|
|
561
549
|
switch (kind) {
|
|
562
550
|
case "add":
|
|
563
551
|
case "change": {
|
|
@@ -772,7 +760,12 @@ async function executeEffect(
|
|
|
772
760
|
|
|
773
761
|
case "SEND_MESSAGE": {
|
|
774
762
|
if (syncState.socket) {
|
|
775
|
-
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}`)
|
|
776
769
|
}
|
|
777
770
|
return []
|
|
778
771
|
}
|
|
@@ -884,7 +877,6 @@ async function executeEffect(
|
|
|
884
877
|
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
885
878
|
return []
|
|
886
879
|
}
|
|
887
|
-
|
|
888
880
|
try {
|
|
889
881
|
// Send change to plugin
|
|
890
882
|
if (syncState.socket) {
|
|
@@ -967,21 +959,16 @@ async function executeEffect(
|
|
|
967
959
|
* Starts the sync controller with the given configuration
|
|
968
960
|
*/
|
|
969
961
|
export async function start(config: Config): Promise<void> {
|
|
970
|
-
info("🚀 Starting
|
|
962
|
+
info("🚀 Starting Code Link")
|
|
971
963
|
info(`Project: ${config.projectHash}`)
|
|
972
964
|
info(`Port: ${config.port} (auto-selected from project hash)`)
|
|
973
965
|
|
|
974
|
-
// State Initialization
|
|
975
|
-
|
|
976
|
-
// Note: We defer project directory creation until handshake if not already set
|
|
977
|
-
// This allows us to receive the project name from the plugin
|
|
978
|
-
|
|
979
966
|
const hashTracker = createHashTracker()
|
|
980
967
|
const fileMetadataCache = new FileMetadataCache()
|
|
981
968
|
let installer: Installer | null = null
|
|
982
969
|
|
|
983
970
|
// State machine state
|
|
984
|
-
|
|
971
|
+
let syncState: SyncState = {
|
|
985
972
|
mode: "disconnected",
|
|
986
973
|
socket: null,
|
|
987
974
|
queuedDiffs: [],
|
|
@@ -994,11 +981,30 @@ export async function start(config: Config): Promise<void> {
|
|
|
994
981
|
// State Machine Execution Helper
|
|
995
982
|
// Process events through state machine and execute effects recursively
|
|
996
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
|
+
|
|
997
989
|
const result = transition(syncState, event)
|
|
998
|
-
|
|
990
|
+
syncState = result.state
|
|
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
|
+
}
|
|
999
997
|
|
|
1000
998
|
// Execute all effects and process any follow-up events
|
|
1001
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
|
+
|
|
1002
1008
|
const followUpEvents = await executeEffect(effect, {
|
|
1003
1009
|
config,
|
|
1004
1010
|
hashTracker,
|
|
@@ -1069,9 +1075,17 @@ export async function start(config: Config): Promise<void> {
|
|
|
1069
1075
|
event = { type: "REQUEST_FILES" }
|
|
1070
1076
|
break
|
|
1071
1077
|
|
|
1072
|
-
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
|
+
)
|
|
1073
1086
|
event = { type: "FILE_LIST", files: message.files }
|
|
1074
1087
|
break
|
|
1088
|
+
}
|
|
1075
1089
|
|
|
1076
1090
|
case "file-change":
|
|
1077
1091
|
event = {
|
|
@@ -1079,6 +1093,8 @@ export async function start(config: Config): Promise<void> {
|
|
|
1079
1093
|
file: {
|
|
1080
1094
|
name: message.fileName,
|
|
1081
1095
|
content: message.content,
|
|
1096
|
+
// Remote modifiedAt is expensive to compute (requires getVerions API call), so we
|
|
1097
|
+
// use local receipt time. Conflict detection uses content hashes, not timestamps.
|
|
1082
1098
|
modifiedAt: Date.now(),
|
|
1083
1099
|
},
|
|
1084
1100
|
fileMeta: fileMetadataCache.get(message.fileName),
|
|
@@ -1178,6 +1194,10 @@ export async function start(config: Config): Promise<void> {
|
|
|
1178
1194
|
info("Will perform full diff on reconnect")
|
|
1179
1195
|
})
|
|
1180
1196
|
|
|
1197
|
+
connection.on("error", (err) => {
|
|
1198
|
+
error("Error on WebSocket connection:", err)
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1181
1201
|
// File Watcher Setup
|
|
1182
1202
|
// Note: Watcher will be initialized after handshake when filesDir is set
|
|
1183
1203
|
|
|
@@ -16,12 +16,14 @@ export interface ConnectionCallbacks {
|
|
|
16
16
|
) => void
|
|
17
17
|
onMessage: (message: IncomingMessage) => void
|
|
18
18
|
onDisconnect: () => void
|
|
19
|
+
onError: (error: Error) => void
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface Connection {
|
|
22
23
|
on(event: "handshake", handler: ConnectionCallbacks["onHandshake"]): void
|
|
23
24
|
on(event: "message", handler: ConnectionCallbacks["onMessage"]): void
|
|
24
25
|
on(event: "disconnect", handler: ConnectionCallbacks["onDisconnect"]): void
|
|
26
|
+
on(event: "error", handler: ConnectionCallbacks["onError"]): void
|
|
25
27
|
close(): void
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -31,11 +33,13 @@ export interface Connection {
|
|
|
31
33
|
export function initConnection(port: number): Connection {
|
|
32
34
|
const wss = new WebSocketServer({ port })
|
|
33
35
|
const handlers: Partial<ConnectionCallbacks> = {}
|
|
36
|
+
let connectionId = 0
|
|
34
37
|
|
|
35
38
|
info(`WebSocket server listening on port ${port}`)
|
|
36
39
|
|
|
37
40
|
wss.on("connection", (ws: WebSocket) => {
|
|
38
|
-
|
|
41
|
+
const connId = ++connectionId
|
|
42
|
+
info(`[CONN ${connId}] Client connected (readyState: ${ws.readyState})`)
|
|
39
43
|
|
|
40
44
|
ws.on("message", (data: Buffer) => {
|
|
41
45
|
try {
|
|
@@ -43,33 +47,41 @@ export function initConnection(port: number): Connection {
|
|
|
43
47
|
|
|
44
48
|
// Special handling for handshake
|
|
45
49
|
if (message.type === "handshake") {
|
|
50
|
+
info(`[CONN ${connId}] Received handshake`)
|
|
46
51
|
handlers.onHandshake?.(ws, message)
|
|
47
52
|
} else {
|
|
48
53
|
handlers.onMessage?.(message)
|
|
49
54
|
}
|
|
50
55
|
} catch (err) {
|
|
51
|
-
error(
|
|
56
|
+
error(`[CONN ${connId}] Failed to parse message:`, err)
|
|
52
57
|
}
|
|
53
58
|
})
|
|
54
59
|
|
|
55
|
-
ws.on("close", () => {
|
|
56
|
-
info(
|
|
60
|
+
ws.on("close", (code, reason) => {
|
|
61
|
+
info(
|
|
62
|
+
`[CONN ${connId}] Client disconnected (code: ${code}, reason: ${reason?.toString() || "none"})`
|
|
63
|
+
)
|
|
57
64
|
handlers.onDisconnect?.()
|
|
58
65
|
})
|
|
59
66
|
|
|
60
67
|
ws.on("error", (err) => {
|
|
61
|
-
error(
|
|
68
|
+
error(`[CONN ${connId}] WebSocket error:`, err)
|
|
62
69
|
})
|
|
63
70
|
})
|
|
64
71
|
|
|
65
72
|
return {
|
|
66
|
-
on(
|
|
73
|
+
on(
|
|
74
|
+
event: "handshake" | "message" | "disconnect" | "error",
|
|
75
|
+
handler: any
|
|
76
|
+
): void {
|
|
67
77
|
if (event === "handshake") {
|
|
68
78
|
handlers.onHandshake = handler
|
|
69
79
|
} else if (event === "message") {
|
|
70
80
|
handlers.onMessage = handler
|
|
71
81
|
} else if (event === "disconnect") {
|
|
72
82
|
handlers.onDisconnect = handler
|
|
83
|
+
} else if (event === "error") {
|
|
84
|
+
handlers.onError = handler
|
|
73
85
|
}
|
|
74
86
|
},
|
|
75
87
|
|
|
@@ -79,17 +91,50 @@ export function initConnection(port: number): Connection {
|
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
/**
|
|
95
|
+
* WebSocket readyState constants for reference
|
|
96
|
+
*/
|
|
97
|
+
const READY_STATE = {
|
|
98
|
+
CONNECTING: 0,
|
|
99
|
+
OPEN: 1,
|
|
100
|
+
CLOSING: 2,
|
|
101
|
+
CLOSED: 3,
|
|
102
|
+
} as const
|
|
103
|
+
|
|
104
|
+
function readyStateToString(state: number): string {
|
|
105
|
+
switch (state) {
|
|
106
|
+
case 0: return "CONNECTING"
|
|
107
|
+
case 1: return "OPEN"
|
|
108
|
+
case 2: return "CLOSING"
|
|
109
|
+
case 3: return "CLOSED"
|
|
110
|
+
default: return `UNKNOWN(${state})`
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
/**
|
|
83
115
|
* Sends a message to a connected socket
|
|
116
|
+
* Returns false if the socket is not open (instead of throwing)
|
|
84
117
|
*/
|
|
85
118
|
export function sendMessage(
|
|
86
119
|
socket: WebSocket,
|
|
87
120
|
message: OutgoingMessage
|
|
88
|
-
): Promise<
|
|
89
|
-
return new Promise((resolve
|
|
121
|
+
): Promise<boolean> {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
// Check socket state before attempting to send
|
|
124
|
+
if (socket.readyState !== READY_STATE.OPEN) {
|
|
125
|
+
const stateStr = readyStateToString(socket.readyState)
|
|
126
|
+
info(`[WS] Cannot send ${message.type}: socket is ${stateStr}`)
|
|
127
|
+
resolve(false)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
90
131
|
socket.send(JSON.stringify(message), (err) => {
|
|
91
|
-
if (err)
|
|
92
|
-
|
|
132
|
+
if (err) {
|
|
133
|
+
error(`[WS] Send error for ${message.type}:`, err.message)
|
|
134
|
+
resolve(false)
|
|
135
|
+
} else {
|
|
136
|
+
resolve(true)
|
|
137
|
+
}
|
|
93
138
|
})
|
|
94
139
|
})
|
|
95
140
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,14 +12,26 @@ 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
|
|
|
19
|
+
program.exitOverride((err) => {
|
|
20
|
+
if (err.code === "commander.missingArgument") {
|
|
21
|
+
console.error("Missing Project ID. Copy command via Code Link Plugin.")
|
|
22
|
+
process.exit(err.exitCode ?? 1)
|
|
23
|
+
}
|
|
24
|
+
throw err
|
|
25
|
+
})
|
|
26
|
+
|
|
18
27
|
program
|
|
19
28
|
.name("code-link")
|
|
20
29
|
.description("Sync Framer code components to your local filesystem")
|
|
21
30
|
.version("0.1.0")
|
|
22
|
-
.argument(
|
|
31
|
+
.argument(
|
|
32
|
+
"[projectHash]",
|
|
33
|
+
"Framer Project ID Hash (auto-detected from package.json if omitted)"
|
|
34
|
+
)
|
|
23
35
|
.option("-n, --name <name>", "Project name (optional)")
|
|
24
36
|
.option("-d, --dir <directory>", "Explicit project directory")
|
|
25
37
|
.option("-v, --verbose", "Enable verbose logging")
|
|
@@ -28,7 +40,23 @@ program
|
|
|
28
40
|
"--dangerously-auto-delete",
|
|
29
41
|
"Automatically delete remote files without confirmation"
|
|
30
42
|
)
|
|
31
|
-
.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
|
+
|
|
32
60
|
// Auto-enable debug in development unless overridden
|
|
33
61
|
const isDev = process.env.NODE_ENV === "development"
|
|
34
62
|
|
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,
|