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 CHANGED
@@ -1,196 +1,3 @@
1
- # Framer Code Link CLI - Next Generation
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
- /** One-liner log effect builder */
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 Framer Code Link CLI (Next Gen)");
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
- const syncState = {
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
- Object.assign(syncState, result.state);
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.name("code-link").description("Sync Framer code components to your local filesystem").version("0.1.0").argument("<projectHash>", "Framer project 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) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
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
- /** One-liner log effect builder */
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 Framer Code Link CLI (Next Gen)")
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
- const syncState: SyncState = {
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
- Object.assign(syncState, result.state)
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
- info("Client connected")
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("Failed to parse message:", err)
56
+ error(`[CONN ${connId}] Failed to parse message:`, err)
52
57
  }
53
58
  })
54
59
 
55
- ws.on("close", () => {
56
- info("Client disconnected")
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("WebSocket error:", err)
68
+ error(`[CONN ${connId}] WebSocket error:`, err)
62
69
  })
63
70
  })
64
71
 
65
72
  return {
66
- on(event: "handshake" | "message" | "disconnect", handler: any): void {
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<void> {
89
- return new Promise((resolve, reject) => {
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) reject(err)
92
- else resolve()
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("<projectHash>", "Framer project hash")
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
 
@@ -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 = toPackageName(projectName)
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: dirName || projectHash,
67
+ name: pkgName || projectHash,
49
68
  version: "1.0.0",
50
69
  private: true,
51
70
  framerProjectId: projectHash,