framer-code-link 0.2.0 → 0.2.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.
@@ -1,79 +0,0 @@
1
- /**
2
- * Hash tracking utilities for echo prevention
3
- *
4
- * The hash tracker prevents echo loops by remembering content hashes
5
- * and skipping watcher events for files we just wrote.
6
- */
7
-
8
- import { createHash } from "crypto"
9
-
10
- export interface HashTracker {
11
- remember(filePath: string, content: string): void
12
- shouldSkip(filePath: string, content: string): boolean
13
- forget(filePath: string): void
14
- clear(): void
15
- markDelete(filePath: string): void
16
- shouldSkipDelete(filePath: string): boolean
17
- clearDelete(filePath: string): void
18
- }
19
-
20
- /**
21
- * Creates a hash tracker instance for echo prevention
22
- */
23
- export function createHashTracker(): HashTracker {
24
- const hashes = new Map<string, string>()
25
- const pendingDeletes = new Map<string, ReturnType<typeof setTimeout>>()
26
-
27
- return {
28
- remember(filePath: string, content: string): void {
29
- const hash = hashContent(content)
30
- hashes.set(filePath, hash)
31
- },
32
-
33
- shouldSkip(filePath: string, content: string): boolean {
34
- const currentHash = hashContent(content)
35
- const storedHash = hashes.get(filePath)
36
- return storedHash === currentHash
37
- },
38
-
39
- forget(filePath: string): void {
40
- hashes.delete(filePath)
41
- },
42
-
43
- clear(): void {
44
- hashes.clear()
45
- },
46
-
47
- markDelete(filePath: string): void {
48
- const existingTimer = pendingDeletes.get(filePath)
49
- if (existingTimer) {
50
- clearTimeout(existingTimer)
51
- }
52
-
53
- const timeout = setTimeout(() => {
54
- pendingDeletes.delete(filePath)
55
- }, 5000)
56
-
57
- pendingDeletes.set(filePath, timeout)
58
- },
59
-
60
- shouldSkipDelete(filePath: string): boolean {
61
- return pendingDeletes.has(filePath)
62
- },
63
-
64
- clearDelete(filePath: string): void {
65
- const timeout = pendingDeletes.get(filePath)
66
- if (timeout) {
67
- clearTimeout(timeout)
68
- }
69
- pendingDeletes.delete(filePath)
70
- },
71
- }
72
- }
73
-
74
- /**
75
- * Computes a SHA256 hash of file content for comparison
76
- */
77
- function hashContent(content: string): string {
78
- return createHash("sha256").update(content).digest("hex")
79
- }
@@ -1,62 +0,0 @@
1
- /**
2
- * Utilities for parsing import statements and extracting package information.
3
- */
4
-
5
- export interface ImportInfo {
6
- type: "npm" | "url"
7
- name: string
8
- raw: string
9
- }
10
-
11
- /**
12
- * Extract npm and URL-based imports from source code.
13
- */
14
- export function extractImports(code: string): ImportInfo[] {
15
- const imports: ImportInfo[] = []
16
- const seen = new Set<string>()
17
-
18
- const npmRegex =
19
- /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^.\/][^'"]+)['"]/g
20
- const urlRegex =
21
- /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g
22
-
23
- let match: RegExpExecArray | null
24
-
25
- while ((match = npmRegex.exec(code)) !== null) {
26
- const pkgName = match[1]
27
- const normalized = pkgName.startsWith("@")
28
- ? pkgName.split("/").slice(0, 2).join("/")
29
- : pkgName.split("/")[0]
30
-
31
- if (!seen.has(normalized)) {
32
- seen.add(normalized)
33
- imports.push({
34
- type: "npm",
35
- name: normalized,
36
- raw: match[0],
37
- })
38
- }
39
- }
40
-
41
- while ((match = urlRegex.exec(code)) !== null) {
42
- const pkgName = extractPackageFromUrl(match[0])
43
- if (pkgName && !seen.has(pkgName)) {
44
- seen.add(pkgName)
45
- imports.push({
46
- type: "url",
47
- name: pkgName,
48
- raw: match[0],
49
- })
50
- }
51
- }
52
-
53
- return imports
54
- }
55
-
56
- /**
57
- * Attempt to derive an npm-style package specifier from a URL import.
58
- */
59
- export function extractPackageFromUrl(url: string): string | null {
60
- const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/)
61
- return match?.[1] ?? null
62
- }
@@ -1,235 +0,0 @@
1
- /**
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
10
- */
11
-
12
- import pc from "picocolors"
13
-
14
- export enum LogLevel {
15
- DEBUG = 0,
16
- INFO = 1,
17
- WARN = 2,
18
- ERROR = 3,
19
- }
20
-
21
- let currentLevel = LogLevel.INFO
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
-
47
- export function setLogLevel(level: LogLevel): void {
48
- currentLevel = level
49
- }
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
- */
103
- export function debug(message: string, ...args: unknown[]): void {
104
- if (currentLevel <= LogLevel.DEBUG) {
105
- console.debug(pc.dim(`[DEBUG] ${message}`), ...args)
106
- }
107
- }
108
-
109
- /**
110
- * Info-level logging - shown by default, no prefix
111
- */
112
- export function info(message: string, ...args: unknown[]): void {
113
- if (currentLevel <= LogLevel.INFO) {
114
- const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message
115
- logWithDedupe(formatted, () => console.log(formatted))
116
- }
117
- }
118
-
119
- /**
120
- * Warning-level logging
121
- */
122
- export function warn(message: string, ...args: unknown[]): void {
123
- if (currentLevel <= LogLevel.WARN) {
124
- if (message === lastMessage) return // Skip exact duplicates silently
125
- flushDedupe()
126
- lastMessage = message
127
- lastMessageCount = 1
128
- console.warn(pc.yellow(`⚠ ${message}`), ...args)
129
- }
130
- }
131
-
132
- /**
133
- * Error-level logging
134
- */
135
- export function error(message: string, ...args: unknown[]): void {
136
- if (currentLevel <= LogLevel.ERROR) {
137
- flushDedupe()
138
- console.error(pc.red(`✗ ${message}`), ...args)
139
- }
140
- }
141
-
142
- /**
143
- * Success message with checkmark
144
- */
145
- export function success(message: string, ...args: unknown[]): void {
146
- if (currentLevel <= LogLevel.INFO) {
147
- flushDedupe()
148
- console.log(pc.green(`✓ ${message}`), ...args)
149
- }
150
- }
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,76 +0,0 @@
1
- /**
2
- * Path manipulation utilities
3
- */
4
-
5
- import path from "path"
6
- import { fileURLToPath } from "url"
7
-
8
- /**
9
- * Resolves a path relative to the project directory
10
- */
11
- export function resolveProjectPath(
12
- projectDir: string,
13
- relativePath: string
14
- ): string {
15
- return path.resolve(projectDir, relativePath)
16
- }
17
-
18
- /**
19
- * Gets a relative path from the project directory
20
- */
21
- export function getRelativePath(
22
- projectDir: string,
23
- absolutePath: string
24
- ): string {
25
- return path.relative(projectDir, absolutePath)
26
- }
27
-
28
- /**
29
- * Ensures a directory path ends with a separator
30
- */
31
- export function ensureTrailingSlash(dirPath: string): string {
32
- return dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep
33
- }
34
-
35
- /**
36
- * Gets the directory name from an import.meta.url (ESM)
37
- */
38
- export function getDirname(importMetaUrl: string): string {
39
- return path.dirname(fileURLToPath(importMetaUrl))
40
- }
41
-
42
- /**
43
- * Normalizes a file path by:
44
- * - Converting backslashes to forward slashes
45
- * - Resolving . and .. segments
46
- * - Removing duplicate slashes
47
- */
48
- export function normalizePath(filePath: string): string {
49
- if (!filePath) return ""
50
-
51
- const isAbsolute = filePath.startsWith("/")
52
- const segments = filePath.replace(/\\/g, "/").split("/")
53
- const stack: string[] = []
54
-
55
- for (const segment of segments) {
56
- if (!segment || segment === ".") {
57
- continue
58
- }
59
-
60
- if (segment === "..") {
61
- if (stack.length > 0) {
62
- stack.pop()
63
- }
64
- continue
65
- }
66
-
67
- stack.push(segment)
68
- }
69
-
70
- const normalized = stack.join("/")
71
- if (isAbsolute) {
72
- return `/${normalized}`
73
- }
74
-
75
- return normalized
76
- }
@@ -1,120 +0,0 @@
1
- import fs from "fs/promises"
2
- import path from "path"
3
- import { shortProjectHash } from "@code-link/shared"
4
-
5
- interface PackageJson {
6
- shortProjectHash?: string // derived short id (8 chars base58)
7
- framerProjectHash?: string // full 64-char hex hash from Framer API
8
- framerProjectName?: string
9
- name?: string
10
- version?: string
11
- [key: string]: unknown
12
- }
13
-
14
- export function toPackageName(name: string): string {
15
- return name
16
- .toLowerCase()
17
- .replace(/[^a-z0-9-]/g, "-")
18
- .replace(/^-+|-+$/g, "")
19
- .replace(/-+/g, "-")
20
- }
21
-
22
- export function toDirName(name: string): string {
23
- return name
24
- .replace(/[^a-zA-Z0-9- ]/g, "-")
25
- .replace(/^[-\s]+|[-\s]+$/g, "")
26
- .replace(/-+/g, "-")
27
- }
28
-
29
- export async function getProjectHashFromCwd(): Promise<string | null> {
30
- try {
31
- const packageJsonPath = path.join(process.cwd(), "package.json")
32
- const content = await fs.readFile(packageJsonPath, "utf-8")
33
- const pkg = JSON.parse(content) as PackageJson
34
- // Return short id for port derivation
35
- return pkg.shortProjectHash ?? null
36
- } catch {
37
- return null
38
- }
39
- }
40
-
41
- export async function findOrCreateProjectDir(
42
- projectHash: string,
43
- projectName?: string,
44
- explicitDir?: string
45
- ): Promise<string> {
46
- if (explicitDir) {
47
- const resolved = path.resolve(explicitDir)
48
- await fs.mkdir(path.join(resolved, "files"), { recursive: true })
49
- return resolved
50
- }
51
-
52
- const cwd = process.cwd()
53
- const existing = await findExistingProjectDir(cwd, projectHash)
54
- if (existing) {
55
- return existing
56
- }
57
-
58
- if (!projectName) {
59
- throw new Error(
60
- "Project name is required when creating a new workspace. Pass --name <project name>."
61
- )
62
- }
63
-
64
- const dirName = toDirName(projectName)
65
- const pkgName = toPackageName(projectName)
66
- const shortId = shortProjectHash(projectHash)
67
- const projectDir = path.join(cwd, dirName || shortId)
68
-
69
- await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
70
- const pkg: PackageJson = {
71
- name: pkgName || shortId,
72
- version: "1.0.0",
73
- private: true,
74
- shortProjectHash: shortId,
75
- framerProjectHash: projectHash,
76
- framerProjectName: projectName,
77
- }
78
- await fs.writeFile(
79
- path.join(projectDir, "package.json"),
80
- JSON.stringify(pkg, null, 2)
81
- )
82
-
83
- return projectDir
84
- }
85
-
86
- async function findExistingProjectDir(
87
- baseDir: string,
88
- projectHash: string
89
- ): Promise<string | null> {
90
- const candidate = path.join(baseDir, "package.json")
91
- if (await matchesProject(candidate, projectHash)) {
92
- return baseDir
93
- }
94
-
95
- const entries = await fs.readdir(baseDir, { withFileTypes: true })
96
- for (const entry of entries) {
97
- if (!entry.isDirectory()) continue
98
- const dir = path.join(baseDir, entry.name)
99
- if (await matchesProject(path.join(dir, "package.json"), projectHash)) {
100
- return dir
101
- }
102
- }
103
-
104
- return null
105
- }
106
-
107
- async function matchesProject(
108
- packageJsonPath: string,
109
- projectHash: string
110
- ): Promise<boolean> {
111
- try {
112
- const content = await fs.readFile(packageJsonPath, "utf-8")
113
- const pkg = JSON.parse(content) as PackageJson
114
- const inputShort = shortProjectHash(projectHash)
115
- // Match on short id (handles both full hash input and short id input)
116
- return pkg.shortProjectHash === inputShort
117
- } catch {
118
- return false
119
- }
120
- }
@@ -1,138 +0,0 @@
1
- /**
2
- * State persistence helper
3
- *
4
- * Persists last sync timestamps along with content hashes.
5
- * We only trust persisted timestamps if the file content hasn't changed
6
- * (hash matches), because that means the file wasn't edited while CLI was offline.
7
- */
8
-
9
- import fs from "fs/promises"
10
- import path from "path"
11
- import { createHash } from "crypto"
12
- import { debug, warn } from "./logging.js"
13
- import { normalizePath } from "./paths.js"
14
-
15
- export interface PersistedFileState {
16
- timestamp: number // Remote modified timestamp from last sync
17
- contentHash: string // Hash of content when we received the sync confirmation
18
- }
19
-
20
- interface PersistedState {
21
- version: number
22
- files: Record<string, PersistedFileState>
23
- }
24
-
25
- const STATE_FILE_NAME = ".framer-sync-state.json"
26
- // Version 2: State machine rewrite - only stores hashes/timestamps for local edit detection
27
- // Breaking change: users must delete old .framer-sync-state.json files
28
- const CURRENT_VERSION = 2
29
- const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
30
- const DEFAULT_EXTENSION = ".tsx"
31
-
32
- export function normalizePersistedFileName(fileName: string): string {
33
- let normalized = normalizePath(fileName.trim())
34
- if (
35
- !SUPPORTED_EXTENSIONS.some((ext) => normalized.toLowerCase().endsWith(ext))
36
- ) {
37
- normalized = `${normalized}${DEFAULT_EXTENSION}`
38
- }
39
- return normalized
40
- }
41
-
42
- /**
43
- * Hash file content to detect changes
44
- */
45
- export function hashFileContent(content: string): string {
46
- return createHash("sha256").update(content, "utf-8").digest("hex")
47
- }
48
-
49
- /**
50
- * Load persisted state from disk
51
- */
52
- export async function loadPersistedState(
53
- projectDir: string
54
- ): Promise<Map<string, PersistedFileState>> {
55
- const statePath = path.join(projectDir, STATE_FILE_NAME)
56
- const result = new Map<string, PersistedFileState>()
57
-
58
- try {
59
- const data = await fs.readFile(statePath, "utf-8")
60
- const parsed: PersistedState = JSON.parse(data)
61
-
62
- if (parsed.version !== CURRENT_VERSION) {
63
- warn(
64
- `State file version mismatch (expected ${CURRENT_VERSION}, got ${parsed.version}). Ignoring persisted state.`
65
- )
66
- return result
67
- }
68
-
69
- for (const [fileName, state] of Object.entries(parsed.files)) {
70
- const normalizedName = normalizePersistedFileName(fileName)
71
- if (normalizedName !== fileName) {
72
- debug(
73
- `Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`
74
- )
75
- }
76
- result.set(normalizedName, state)
77
- }
78
-
79
- debug(`Loaded persisted state for ${result.size} files`)
80
- return result
81
- } catch (err) {
82
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
83
- debug("No persisted state found (first run)")
84
- return result
85
- }
86
- warn("Failed to load persisted state:", err)
87
- return result
88
- }
89
- }
90
-
91
- /**
92
- * Save current state to disk
93
- */
94
- export async function savePersistedState(
95
- projectDir: string,
96
- state: Map<string, PersistedFileState>
97
- ): Promise<void> {
98
- const statePath = path.join(projectDir, STATE_FILE_NAME)
99
-
100
- const persistedState: PersistedState = {
101
- version: CURRENT_VERSION,
102
- files: Object.fromEntries(state.entries()),
103
- }
104
-
105
- try {
106
- await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2))
107
- debug(`Saved persisted state for ${state.size} files`)
108
- } catch (err) {
109
- warn("Failed to save persisted state:", err)
110
- }
111
- }
112
-
113
- /**
114
- * Validate persisted timestamp against current file content
115
- * Returns the timestamp only if the content hash matches (file unchanged)
116
- */
117
- export function validatePersistedTimestamp(
118
- persistedState: PersistedFileState | undefined,
119
- currentContent: string
120
- ): number | null {
121
- if (!persistedState) {
122
- return null
123
- }
124
-
125
- const currentHash = hashFileContent(currentContent)
126
-
127
- if (currentHash === persistedState.contentHash) {
128
- debug(
129
- `Hash matches for persisted state - trusting timestamp ${persistedState.timestamp}`
130
- )
131
- return persistedState.timestamp
132
- }
133
-
134
- debug(
135
- "Hash mismatch for persisted state - file was edited while CLI was offline"
136
- )
137
- return null
138
- }