framer-code-link 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * Core types for the controller-centric CLI architecture
3
3
  */
4
4
 
5
- import type { WebSocket } from "ws"
6
5
  import type { PendingDelete } from "@code-link/shared"
7
6
 
8
7
  // Configuration
@@ -30,10 +29,14 @@ export interface LocalFile {
30
29
  }
31
30
 
32
31
  // Conflict detection
32
+ // Deletions are represented by null content
33
+ // For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null
33
34
  export interface Conflict {
34
35
  fileName: string
35
- localContent: string
36
- remoteContent: string
36
+ /** null means the file was deleted locally */
37
+ localContent: string | null
38
+ /** null means the file was deleted in Framer */
39
+ remoteContent: string | null
37
40
  localModifiedAt?: number
38
41
  remoteModifiedAt?: number
39
42
  lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
@@ -48,6 +51,7 @@ export interface ConflictResolution {
48
51
  conflicts: Conflict[]
49
52
  writes: FileInfo[]
50
53
  localOnly: FileInfo[]
54
+ unchanged: FileInfo[]
51
55
  }
52
56
 
53
57
  // Watcher events
@@ -105,3 +109,4 @@ export type OutgoingMessage =
105
109
  type: "conflict-version-request"
106
110
  conflicts: ConflictVersionRequest[]
107
111
  }
112
+ | { type: "sync-complete" }
@@ -72,24 +72,8 @@ export function createHashTracker(): HashTracker {
72
72
  }
73
73
 
74
74
  /**
75
- * Computes a hash of file content for comparison
75
+ * Computes a SHA256 hash of file content for comparison
76
76
  */
77
77
  function hashContent(content: string): string {
78
78
  return createHash("sha256").update(content).digest("hex")
79
79
  }
80
-
81
- /**
82
- * Generate a deterministic port number from a project hash
83
- * Port range: 3847-4096 (250 possible ports)
84
- * Uses simple hash to match client-side implementation
85
- */
86
- export function getPortFromHash(projectHash: string): number {
87
- let hash = 0
88
- for (let i = 0; i < projectHash.length; i++) {
89
- const char = projectHash.charCodeAt(i)
90
- hash = (hash << 5) - hash + char
91
- hash = hash & hash // Convert to 32bit integer
92
- }
93
- const portOffset = Math.abs(hash) % 250
94
- return 3847 + portOffset
95
- }
@@ -1,7 +1,16 @@
1
1
  /**
2
- * Logging utilities for consistent output
2
+ * Logging utilities for consistent, clean CLI output
3
+ *
4
+ * Features:
5
+ * - Log levels (DEBUG, INFO, WARN, ERROR)
6
+ * - Message deduplication with count suffix (x2), (x3)
7
+ * - Reconnect cycle suppression (collapses rapid disconnect/reconnect spam)
8
+ * - Clean prefixes (no [INFO] clutter at default level)
9
+ * - Colored startup banner
3
10
  */
4
11
 
12
+ import pc from "picocolors"
13
+
5
14
  export enum LogLevel {
6
15
  DEBUG = 0,
7
16
  INFO = 1,
@@ -11,37 +20,216 @@ export enum LogLevel {
11
20
 
12
21
  let currentLevel = LogLevel.INFO
13
22
 
23
+ // Deduplication state
24
+ let lastMessage = ""
25
+ let lastMessageCount = 0
26
+ const CLEAR_LINE = "\u001b[2K"
27
+ const MOVE_CURSOR_UP = "\u001b[1A"
28
+
29
+ // Redraw the previous line with the updated message/count.
30
+ function rewriteLastLine(text: string): void {
31
+ if (process.stdout.isTTY) {
32
+ process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`)
33
+ } else {
34
+ // Fallback for non-TTY (e.g., piping output) – just emit the line.
35
+ process.stdout.write(`${text}\n`)
36
+ }
37
+ }
38
+
39
+ // Reconnect suppression state
40
+ let disconnectTimer: ReturnType<typeof setTimeout> | null = null
41
+ let isShowingDisconnect = false
42
+ let hadRecentDisconnect = false
43
+ // Only show disconnect if down for 4+ seconds
44
+ // Allows for swtiching between Canvas and Code Editor
45
+ const DISCONNECT_DELAY_MS = 4000
46
+
14
47
  export function setLogLevel(level: LogLevel): void {
15
48
  currentLevel = level
16
49
  }
17
50
 
51
+ export function getLogLevel(): LogLevel {
52
+ return currentLevel
53
+ }
54
+
55
+ function dedupeMessage(message: string, count: number): void {
56
+ rewriteLastLine(`${message} ${pc.dim(`(x${count})`)}`)
57
+ }
58
+
59
+ /**
60
+ * Flush any pending deduplicated message
61
+ */
62
+ function flushDedupe(): void {
63
+ if (lastMessageCount > 1) {
64
+ dedupeMessage(lastMessage, lastMessageCount)
65
+ }
66
+ lastMessage = ""
67
+ lastMessageCount = 0
68
+ }
69
+
70
+ /**
71
+ * Log with deduplication - repeated messages within window get counted
72
+ */
73
+ function logWithDedupe(message: string, writer: () => void): void {
74
+ if (message === lastMessage) {
75
+ // Same message - increment count regardless of gap
76
+ lastMessageCount++
77
+ // Overwrite previous line (move cursor up, clear, rewrite)
78
+ dedupeMessage(message, lastMessageCount)
79
+ return
80
+ }
81
+
82
+ lastMessage = message
83
+ lastMessageCount = 1
84
+ writer()
85
+ }
86
+
87
+ /**
88
+ * Print the startup banner - one colored line
89
+ */
90
+ export function banner(version: string, port: number): void {
91
+ console.log()
92
+ let message = ` ${pc.cyan(pc.bold("⚡ Code Link"))} ${pc.dim(`v${version}`)}`
93
+ if (currentLevel <= LogLevel.DEBUG) {
94
+ message += ` ${pc.dim("Port")} ${pc.yellow(port)}`
95
+ }
96
+ console.log(message)
97
+ console.log()
98
+ }
99
+
100
+ /**
101
+ * Debug-level logging - only shown with --verbose flag
102
+ */
18
103
  export function debug(message: string, ...args: unknown[]): void {
19
104
  if (currentLevel <= LogLevel.DEBUG) {
20
- console.debug(`[DEBUG] ${message}`, ...args)
105
+ console.debug(pc.dim(`[DEBUG] ${message}`), ...args)
21
106
  }
22
107
  }
23
108
 
109
+ /**
110
+ * Info-level logging - shown by default, no prefix
111
+ */
24
112
  export function info(message: string, ...args: unknown[]): void {
25
113
  if (currentLevel <= LogLevel.INFO) {
26
- console.info(`[INFO] ${message}`, ...args)
114
+ const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message
115
+ logWithDedupe(formatted, () => console.log(formatted))
27
116
  }
28
117
  }
29
118
 
119
+ /**
120
+ * Warning-level logging
121
+ */
30
122
  export function warn(message: string, ...args: unknown[]): void {
31
123
  if (currentLevel <= LogLevel.WARN) {
32
- console.warn(`[WARN] ${message}`, ...args)
124
+ if (message === lastMessage) return // Skip exact duplicates silently
125
+ flushDedupe()
126
+ lastMessage = message
127
+ lastMessageCount = 1
128
+ console.warn(pc.yellow(`⚠ ${message}`), ...args)
33
129
  }
34
130
  }
35
131
 
132
+ /**
133
+ * Error-level logging
134
+ */
36
135
  export function error(message: string, ...args: unknown[]): void {
37
136
  if (currentLevel <= LogLevel.ERROR) {
38
- console.error(`[ERROR] ${message}`, ...args)
137
+ flushDedupe()
138
+ console.error(pc.red(`✗ ${message}`), ...args)
39
139
  }
40
140
  }
41
141
 
142
+ /**
143
+ * Success message with checkmark
144
+ */
42
145
  export function success(message: string, ...args: unknown[]): void {
43
146
  if (currentLevel <= LogLevel.INFO) {
44
- console.log(`✓ ${message}`, ...args)
147
+ flushDedupe()
148
+ console.log(pc.green(`✓ ${message}`), ...args)
45
149
  }
46
150
  }
47
151
 
152
+ /**
153
+ * File sync indicators
154
+ */
155
+ export function fileDown(fileName: string): void {
156
+ if (currentLevel <= LogLevel.INFO) {
157
+ const msg = ` ${pc.blue("↓")} ${fileName}`
158
+ logWithDedupe(msg, () => console.log(msg))
159
+ }
160
+ }
161
+
162
+ export function fileUp(fileName: string): void {
163
+ if (currentLevel <= LogLevel.INFO) {
164
+ const msg = ` ${pc.green("↑")} ${fileName}`
165
+ logWithDedupe(msg, () => console.log(msg))
166
+ }
167
+ }
168
+
169
+ export function fileDelete(fileName: string): void {
170
+ if (currentLevel <= LogLevel.INFO) {
171
+ const msg = ` ${pc.red("×")} ${fileName}`
172
+ logWithDedupe(msg, () => console.log(msg))
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Status message (dimmed, for "watching for changes..." etc)
178
+ */
179
+ export function status(message: string): void {
180
+ if (currentLevel <= LogLevel.INFO) {
181
+ flushDedupe()
182
+ console.log(pc.dim(` ${message}`))
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Schedule a delayed disconnect message.
188
+ * If reconnection happens before the delay, the message is cancelled.
189
+ */
190
+ export function scheduleDisconnectMessage(callback: () => void): void {
191
+ // Clear any existing timer
192
+ if (disconnectTimer) {
193
+ clearTimeout(disconnectTimer)
194
+ }
195
+
196
+ hadRecentDisconnect = true
197
+ isShowingDisconnect = false
198
+ disconnectTimer = setTimeout(() => {
199
+ isShowingDisconnect = true
200
+ callback()
201
+ disconnectTimer = null
202
+ }, DISCONNECT_DELAY_MS)
203
+ }
204
+
205
+ /**
206
+ * Cancel pending disconnect message (called on reconnect)
207
+ */
208
+ export function cancelDisconnectMessage(): void {
209
+ if (disconnectTimer) {
210
+ clearTimeout(disconnectTimer)
211
+ disconnectTimer = null
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Check if we showed a disconnect message (need to show reconnect)
217
+ */
218
+ export function didShowDisconnect(): boolean {
219
+ return isShowingDisconnect
220
+ }
221
+
222
+ /**
223
+ * Check if we recently saw a disconnect (even if the message was suppressed)
224
+ */
225
+ export function wasRecentlyDisconnected(): boolean {
226
+ return hadRecentDisconnect
227
+ }
228
+
229
+ /**
230
+ * Reset disconnect state after successful reconnect
231
+ */
232
+ export function resetDisconnectState(): void {
233
+ isShowingDisconnect = false
234
+ hadRecentDisconnect = false
235
+ }
@@ -1,8 +1,10 @@
1
1
  import fs from "fs/promises"
2
2
  import path from "path"
3
+ import { shortProjectHash } from "@code-link/shared"
3
4
 
4
5
  interface PackageJson {
5
- framerProjectId?: string
6
+ shortProjectHash?: string // derived short id (8 chars base58)
7
+ framerProjectHash?: string // full 64-char hex hash from Framer API
6
8
  framerProjectName?: string
7
9
  name?: string
8
10
  version?: string
@@ -19,8 +21,8 @@ export function toPackageName(name: string): string {
19
21
 
20
22
  export function toDirName(name: string): string {
21
23
  return name
22
- .replace(/[^a-zA-Z0-9-]/g, "-")
23
- .replace(/^-+|-+$/g, "")
24
+ .replace(/[^a-zA-Z0-9- ]/g, "-")
25
+ .replace(/^[-\s]+|[-\s]+$/g, "")
24
26
  .replace(/-+/g, "-")
25
27
  }
26
28
 
@@ -29,7 +31,8 @@ export async function getProjectHashFromCwd(): Promise<string | null> {
29
31
  const packageJsonPath = path.join(process.cwd(), "package.json")
30
32
  const content = await fs.readFile(packageJsonPath, "utf-8")
31
33
  const pkg = JSON.parse(content) as PackageJson
32
- return pkg.framerProjectId ?? null
34
+ // Return short id for port derivation
35
+ return pkg.shortProjectHash ?? null
33
36
  } catch {
34
37
  return null
35
38
  }
@@ -60,14 +63,16 @@ export async function findOrCreateProjectDir(
60
63
 
61
64
  const dirName = toDirName(projectName)
62
65
  const pkgName = toPackageName(projectName)
63
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6))
66
+ const shortId = shortProjectHash(projectHash)
67
+ const projectDir = path.join(cwd, dirName || shortId)
64
68
 
65
69
  await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
66
70
  const pkg: PackageJson = {
67
- name: pkgName || projectHash,
71
+ name: pkgName || shortId,
68
72
  version: "1.0.0",
69
73
  private: true,
70
- framerProjectId: projectHash,
74
+ shortProjectHash: shortId,
75
+ framerProjectHash: projectHash,
71
76
  framerProjectName: projectName,
72
77
  }
73
78
  await fs.writeFile(
@@ -106,7 +111,9 @@ async function matchesProject(
106
111
  try {
107
112
  const content = await fs.readFile(packageJsonPath, "utf-8")
108
113
  const pkg = JSON.parse(content) as PackageJson
109
- return pkg.framerProjectId === projectHash
114
+ const inputShort = shortProjectHash(projectHash)
115
+ // Match on short id (handles both full hash input and short id input)
116
+ return pkg.shortProjectHash === inputShort
110
117
  } catch {
111
118
  return false
112
119
  }
@@ -1,53 +0,0 @@
1
- import fs from "fs/promises";
2
- import path from "path";
3
-
4
- //#region src/utils/project.ts
5
- function toPackageName(name) {
6
- return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
7
- }
8
- async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
9
- if (explicitDir) {
10
- const resolved = path.resolve(explicitDir);
11
- await fs.mkdir(path.join(resolved, "files"), { recursive: true });
12
- return resolved;
13
- }
14
- const cwd = process.cwd();
15
- const existing = await findExistingProjectDir(cwd, projectHash);
16
- if (existing) return existing;
17
- if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
18
- const dirName = toPackageName(projectName);
19
- const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
20
- await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
21
- const pkg = {
22
- name: dirName || projectHash,
23
- version: "1.0.0",
24
- private: true,
25
- framerProjectId: projectHash,
26
- framerProjectName: projectName
27
- };
28
- await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
29
- return projectDir;
30
- }
31
- async function findExistingProjectDir(baseDir, projectHash) {
32
- const candidate = path.join(baseDir, "package.json");
33
- if (await matchesProject(candidate, projectHash)) return baseDir;
34
- const entries = await fs.readdir(baseDir, { withFileTypes: true });
35
- for (const entry of entries) {
36
- if (!entry.isDirectory()) continue;
37
- const dir = path.join(baseDir, entry.name);
38
- if (await matchesProject(path.join(dir, "package.json"), projectHash)) return dir;
39
- }
40
- return null;
41
- }
42
- async function matchesProject(packageJsonPath, projectHash) {
43
- try {
44
- const content = await fs.readFile(packageJsonPath, "utf-8");
45
- const pkg = JSON.parse(content);
46
- return pkg.framerProjectId === projectHash;
47
- } catch {
48
- return false;
49
- }
50
- }
51
-
52
- //#endregion
53
- export { findOrCreateProjectDir };