framer-code-link 0.1.0 → 0.1.1
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 +10 -22
- package/src/helpers/connection.ts +8 -1
- package/src/index.ts +9 -1
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": {
|
|
@@ -884,7 +872,6 @@ async function executeEffect(
|
|
|
884
872
|
if (hashTracker.shouldSkip(effect.fileName, effect.content)) {
|
|
885
873
|
return []
|
|
886
874
|
}
|
|
887
|
-
|
|
888
875
|
try {
|
|
889
876
|
// Send change to plugin
|
|
890
877
|
if (syncState.socket) {
|
|
@@ -967,21 +954,16 @@ async function executeEffect(
|
|
|
967
954
|
* Starts the sync controller with the given configuration
|
|
968
955
|
*/
|
|
969
956
|
export async function start(config: Config): Promise<void> {
|
|
970
|
-
info("🚀 Starting
|
|
957
|
+
info("🚀 Starting Code Link")
|
|
971
958
|
info(`Project: ${config.projectHash}`)
|
|
972
959
|
info(`Port: ${config.port} (auto-selected from project hash)`)
|
|
973
960
|
|
|
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
961
|
const hashTracker = createHashTracker()
|
|
980
962
|
const fileMetadataCache = new FileMetadataCache()
|
|
981
963
|
let installer: Installer | null = null
|
|
982
964
|
|
|
983
965
|
// State machine state
|
|
984
|
-
|
|
966
|
+
let syncState: SyncState = {
|
|
985
967
|
mode: "disconnected",
|
|
986
968
|
socket: null,
|
|
987
969
|
queuedDiffs: [],
|
|
@@ -995,7 +977,7 @@ export async function start(config: Config): Promise<void> {
|
|
|
995
977
|
// Process events through state machine and execute effects recursively
|
|
996
978
|
async function processEvent(event: SyncEvent) {
|
|
997
979
|
const result = transition(syncState, event)
|
|
998
|
-
|
|
980
|
+
syncState = result.state
|
|
999
981
|
|
|
1000
982
|
// Execute all effects and process any follow-up events
|
|
1001
983
|
for (const effect of result.effects) {
|
|
@@ -1079,6 +1061,8 @@ export async function start(config: Config): Promise<void> {
|
|
|
1079
1061
|
file: {
|
|
1080
1062
|
name: message.fileName,
|
|
1081
1063
|
content: message.content,
|
|
1064
|
+
// Remote modifiedAt is expensive to compute (requires getVerions API call), so we
|
|
1065
|
+
// use local receipt time. Conflict detection uses content hashes, not timestamps.
|
|
1082
1066
|
modifiedAt: Date.now(),
|
|
1083
1067
|
},
|
|
1084
1068
|
fileMeta: fileMetadataCache.get(message.fileName),
|
|
@@ -1178,6 +1162,10 @@ export async function start(config: Config): Promise<void> {
|
|
|
1178
1162
|
info("Will perform full diff on reconnect")
|
|
1179
1163
|
})
|
|
1180
1164
|
|
|
1165
|
+
connection.on("error", (err) => {
|
|
1166
|
+
error("Error on WebSocket connection:", err)
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1181
1169
|
// File Watcher Setup
|
|
1182
1170
|
// Note: Watcher will be initialized after handshake when filesDir is set
|
|
1183
1171
|
|
|
@@ -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
|
|
|
@@ -63,13 +65,18 @@ export function initConnection(port: number): Connection {
|
|
|
63
65
|
})
|
|
64
66
|
|
|
65
67
|
return {
|
|
66
|
-
on(
|
|
68
|
+
on(
|
|
69
|
+
event: "handshake" | "message" | "disconnect" | "error",
|
|
70
|
+
handler: any
|
|
71
|
+
): void {
|
|
67
72
|
if (event === "handshake") {
|
|
68
73
|
handlers.onHandshake = handler
|
|
69
74
|
} else if (event === "message") {
|
|
70
75
|
handlers.onMessage = handler
|
|
71
76
|
} else if (event === "disconnect") {
|
|
72
77
|
handlers.onDisconnect = handler
|
|
78
|
+
} else if (event === "error") {
|
|
79
|
+
handlers.onError = handler
|
|
73
80
|
}
|
|
74
81
|
},
|
|
75
82
|
|
package/src/index.ts
CHANGED
|
@@ -15,11 +15,19 @@ import { getPortFromHash } from "./utils/hashing.js"
|
|
|
15
15
|
|
|
16
16
|
const program = new Command()
|
|
17
17
|
|
|
18
|
+
program.exitOverride((err) => {
|
|
19
|
+
if (err.code === "commander.missingArgument") {
|
|
20
|
+
console.error("Missing Project ID. Copy command via Code Link Plugin.")
|
|
21
|
+
process.exit(err.exitCode ?? 1)
|
|
22
|
+
}
|
|
23
|
+
throw err
|
|
24
|
+
})
|
|
25
|
+
|
|
18
26
|
program
|
|
19
27
|
.name("code-link")
|
|
20
28
|
.description("Sync Framer code components to your local filesystem")
|
|
21
29
|
.version("0.1.0")
|
|
22
|
-
.argument("<projectHash>", "Framer
|
|
30
|
+
.argument("<projectHash>", "Framer Project ID Hash")
|
|
23
31
|
.option("-n, --name <name>", "Project name (optional)")
|
|
24
32
|
.option("-d, --dir <directory>", "Explicit project directory")
|
|
25
33
|
.option("-v, --verbose", "Enable verbose logging")
|