framer-code-link 0.1.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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * User Action Coordinator
3
+ *
4
+ * Provides a clean awaitable API for user confirmations via the plugin.
5
+ * Just coordinates the request/response - all business logic stays in the controller.
6
+ */
7
+
8
+ import type { WebSocket } from "ws"
9
+ import type { Conflict } from "../types.js"
10
+ import { sendMessage } from "./connection.js"
11
+ import { info, warn } from "../utils/logging.js"
12
+
13
+ class PluginDisconnectedError extends Error {
14
+ constructor() {
15
+ super("Plugin disconnected")
16
+ this.name = "PluginDisconnectedError"
17
+ }
18
+ }
19
+
20
+ type PendingAction<T> = {
21
+ resolve: (value: T) => void
22
+ reject: (error: Error) => void
23
+ }
24
+
25
+ export class UserActionCoordinator {
26
+ private pendingActions = new Map<string, PendingAction<any>>()
27
+
28
+ /**
29
+ * Sends the delete request to the plugin and awaits the user's decision
30
+ */
31
+ async requestDeleteDecision(
32
+ socket: WebSocket | null,
33
+ {
34
+ fileName,
35
+ requireConfirmation,
36
+ }: { fileName: string; requireConfirmation: boolean }
37
+ ): Promise<boolean> {
38
+ if (!socket) {
39
+ throw new Error("Cannot request delete decision: plugin not connected")
40
+ }
41
+
42
+ if (requireConfirmation) {
43
+ const confirmationPromise = this.awaitConfirmation<boolean>(
44
+ `delete:${fileName}`,
45
+ "delete confirmation"
46
+ )
47
+
48
+ await sendMessage(socket, {
49
+ type: "file-delete",
50
+ fileNames: [fileName],
51
+ requireConfirmation: true,
52
+ })
53
+
54
+ try {
55
+ return await confirmationPromise
56
+ } catch (err) {
57
+ if (err instanceof PluginDisconnectedError) {
58
+ info(
59
+ `[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`
60
+ )
61
+ return false
62
+ }
63
+ throw err
64
+ }
65
+ }
66
+
67
+ await sendMessage(socket, {
68
+ type: "file-delete",
69
+ fileNames: [fileName],
70
+ requireConfirmation: false,
71
+ })
72
+
73
+ return true
74
+ }
75
+
76
+ /**
77
+ * Sends conflicts to the plugin and awaits user resolutions
78
+ */
79
+ async requestConflictDecisions(
80
+ socket: WebSocket | null,
81
+ conflicts: Conflict[]
82
+ ): Promise<Map<string, "local" | "remote">> {
83
+ if (!socket) {
84
+ throw new Error("Cannot request conflict decision: plugin not connected")
85
+ }
86
+
87
+ if (conflicts.length === 0) {
88
+ return new Map()
89
+ }
90
+
91
+ const pending = conflicts.map((conflict) => ({
92
+ fileName: conflict.fileName,
93
+ promise: this.awaitConfirmation<"local" | "remote">(
94
+ `conflict:${conflict.fileName}`,
95
+ "conflict resolution"
96
+ ),
97
+ }))
98
+
99
+ await sendMessage(socket, {
100
+ type: "conflicts-detected",
101
+ conflicts,
102
+ })
103
+
104
+ try {
105
+ const results = await Promise.all(
106
+ pending.map(
107
+ async ({ fileName, promise }) => [fileName, await promise] as const
108
+ )
109
+ )
110
+
111
+ return new Map(results)
112
+ } catch (err) {
113
+ if (err instanceof PluginDisconnectedError) {
114
+ info(
115
+ "[USER-ACTION] Plugin disconnected while awaiting conflict decisions"
116
+ )
117
+ return new Map()
118
+ }
119
+ throw err
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Generic confirmation awaiter
125
+ */
126
+ private awaitConfirmation<T>(
127
+ actionId: string,
128
+ description: string
129
+ ): Promise<T> {
130
+ return new Promise<T>((resolve, reject) => {
131
+ this.pendingActions.set(actionId, { resolve, reject })
132
+ info(`[USER-ACTION] Awaiting ${description}: ${actionId}`)
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Handle incoming confirmation response
138
+ */
139
+ handleConfirmation<T>(actionId: string, value: T): boolean {
140
+ const pending = this.pendingActions.get(actionId)
141
+ if (!pending) {
142
+ warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`)
143
+ return false
144
+ }
145
+
146
+ this.pendingActions.delete(actionId)
147
+ pending.resolve(value)
148
+ info(`[USER-ACTION] Confirmed: ${actionId}`)
149
+ return true
150
+ }
151
+
152
+ /**
153
+ * Cleanup all pending actions (e.g., on disconnect)
154
+ */
155
+ cleanup(): void {
156
+ for (const [actionId, pending] of this.pendingActions.entries()) {
157
+ pending.reject(new PluginDisconnectedError())
158
+ warn(`[USER-ACTION] Cancelled pending action: ${actionId}`)
159
+ }
160
+ this.pendingActions.clear()
161
+ }
162
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * File watcher helper
3
+ *
4
+ * Thin wrapper around chokidar that normalizes file paths and emits
5
+ * only supported file types (ts, tsx, js, json). Controller never worries
6
+ * about addDir or platform separators.
7
+ */
8
+
9
+ import chokidar from "chokidar"
10
+ import fs from "fs/promises"
11
+ import path from "path"
12
+ import type { WatcherEvent } from "../types.js"
13
+ import {
14
+ isSupportedExtension,
15
+ normalizePath,
16
+ sanitizeFilePath,
17
+ } from "@code-link/shared"
18
+ import { getRelativePath } from "../utils/paths.js"
19
+ import { info, debug, warn } from "../utils/logging.js"
20
+
21
+ export interface Watcher {
22
+ on(event: "change", handler: (event: WatcherEvent) => void): void
23
+ close(): Promise<void>
24
+ }
25
+
26
+ /**
27
+ * Initializes a file watcher for the given directory
28
+ */
29
+ export function initWatcher(filesDir: string): Watcher {
30
+ const handlers: Array<(event: WatcherEvent) => void> = []
31
+
32
+ const watcher = chokidar.watch(filesDir, {
33
+ ignored: /(^|[\/\\])\../, // ignore dotfiles
34
+ persistent: true,
35
+ ignoreInitial: false, // Emit add events for existing files so we can sanitize them
36
+ })
37
+
38
+ info(`Watching directory: ${filesDir}`)
39
+
40
+ // Helper to emit normalized events
41
+ const emitEvent = async (
42
+ kind: "add" | "change" | "delete",
43
+ absolutePath: string
44
+ ): Promise<void> => {
45
+ if (!isSupportedExtension(absolutePath)) {
46
+ return
47
+ }
48
+
49
+ const rawRelativePath = normalizePath(
50
+ getRelativePath(filesDir, absolutePath)
51
+ )
52
+ // Don't capitalize - preserve exact file names as they exist
53
+ // This ensures 1:1 sync with Framer without modifying user's casing choices
54
+ const sanitized = sanitizeFilePath(rawRelativePath, false)
55
+ const relativePath = sanitized.path
56
+
57
+ // If the user created a file that doesn't match our sanitization rules,
58
+ // rename it on disk to match what can be synced.
59
+ let effectiveAbsolutePath = absolutePath
60
+ if (relativePath !== rawRelativePath && kind === "add") {
61
+ const newAbsolutePath = path.join(filesDir, relativePath)
62
+ try {
63
+ await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
64
+ await fs.rename(absolutePath, newAbsolutePath)
65
+ info(
66
+ `Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`
67
+ )
68
+ effectiveAbsolutePath = newAbsolutePath
69
+ } catch (err) {
70
+ warn(
71
+ `Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`,
72
+ err
73
+ )
74
+ }
75
+ }
76
+
77
+ let content: string | undefined
78
+ if (kind !== "delete") {
79
+ try {
80
+ content = await fs.readFile(effectiveAbsolutePath, "utf-8")
81
+ } catch (err) {
82
+ debug(`Failed to read file ${relativePath}:`, err)
83
+ return
84
+ }
85
+ }
86
+
87
+ const event: WatcherEvent = {
88
+ kind,
89
+ relativePath,
90
+ content,
91
+ }
92
+
93
+ debug(`Watcher event: ${kind} ${relativePath}`)
94
+
95
+ for (const handler of handlers) {
96
+ handler(event)
97
+ }
98
+ }
99
+
100
+ watcher.on("add", (filePath) => emitEvent("add", filePath))
101
+ watcher.on("change", (filePath) => emitEvent("change", filePath))
102
+ watcher.on("unlink", (filePath) => emitEvent("delete", filePath))
103
+
104
+ return {
105
+ on(event: "change", handler: (event: WatcherEvent) => void): void {
106
+ if (event === "change") {
107
+ handlers.push(handler)
108
+ }
109
+ },
110
+
111
+ async close(): Promise<void> {
112
+ await watcher.close()
113
+ },
114
+ }
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Framer Code Link CLI - Next Generation
5
+ *
6
+ * Entry point for the CLI tool. Parses command-line arguments and starts
7
+ * the controller with the appropriate configuration.
8
+ */
9
+
10
+ import { Command } from "commander"
11
+ import { start } from "./controller.js"
12
+ import type { Config } from "./types.js"
13
+ import { setLogLevel, LogLevel, info } from "./utils/logging.js"
14
+ import { getPortFromHash } from "./utils/hashing.js"
15
+
16
+ const program = new Command()
17
+
18
+ program
19
+ .name("code-link")
20
+ .description("Sync Framer code components to your local filesystem")
21
+ .version("0.1.0")
22
+ .argument("<projectHash>", "Framer project hash")
23
+ .option("-n, --name <name>", "Project name (optional)")
24
+ .option("-d, --dir <directory>", "Explicit project directory")
25
+ .option("-v, --verbose", "Enable verbose logging")
26
+ .option("--log-level <level>", "Set log level (debug, info, warn, error)")
27
+ .option(
28
+ "--dangerously-auto-delete",
29
+ "Automatically delete remote files without confirmation"
30
+ )
31
+ .action(async (projectHash: string, options) => {
32
+ // Auto-enable debug in development unless overridden
33
+ const isDev = process.env.NODE_ENV === "development"
34
+
35
+ if (options.logLevel) {
36
+ const levelMap: Record<string, LogLevel> = {
37
+ debug: LogLevel.DEBUG,
38
+ info: LogLevel.INFO,
39
+ warn: LogLevel.WARN,
40
+ error: LogLevel.ERROR,
41
+ }
42
+ const level = levelMap[options.logLevel.toLowerCase()]
43
+ if (level !== undefined) {
44
+ setLogLevel(level)
45
+ }
46
+ } else if (options.verbose || isDev) {
47
+ setLogLevel(LogLevel.DEBUG)
48
+ }
49
+
50
+ const port = getPortFromHash(projectHash)
51
+
52
+ const config: Config = {
53
+ port,
54
+ projectHash,
55
+ projectDir: null, // Will be set during handshake
56
+ filesDir: null, // Will be set during handshake
57
+ dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
58
+ explicitDir: options.dir,
59
+ explicitName: options.name,
60
+ }
61
+
62
+ if (config.dangerouslyAutoDelete) {
63
+ info(
64
+ "⚠️ Auto-delete mode enabled - files will be deleted without confirmation"
65
+ )
66
+ }
67
+
68
+ await start(config)
69
+ })
70
+
71
+ program.parse()
72
+
73
+ // Export for programmatic usage
74
+ export { start } from "./controller.js"
75
+ export type { Config } from "./types.js"
package/src/types.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Core types for the controller-centric CLI architecture
3
+ */
4
+
5
+ import type { WebSocket } from "ws"
6
+ import type { PendingDelete } from "@code-link/shared"
7
+
8
+ // Configuration
9
+ export interface Config {
10
+ port: number
11
+ projectHash: string
12
+ projectDir: string | null // Set during handshake if not already determined
13
+ filesDir: string | null // Set during handshake if not already determined
14
+ dangerouslyAutoDelete: boolean
15
+ explicitDir?: string // User-provided directory override
16
+ explicitName?: string // User-provided name override
17
+ }
18
+
19
+ // File representations
20
+ export interface FileInfo {
21
+ name: string
22
+ content: string
23
+ modifiedAt?: number
24
+ }
25
+
26
+ export interface LocalFile {
27
+ relativePath: string
28
+ content: string
29
+ modifiedAt?: number
30
+ }
31
+
32
+ // Conflict detection
33
+ export interface Conflict {
34
+ fileName: string
35
+ localContent: string
36
+ remoteContent: string
37
+ localModifiedAt?: number
38
+ remoteModifiedAt?: number
39
+ lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
40
+ /**
41
+ * True when the local file still matches the last persisted hash.
42
+ * Used for auto-resolution heuristics.
43
+ */
44
+ localClean?: boolean
45
+ }
46
+
47
+ export interface ConflictResolution {
48
+ conflicts: Conflict[]
49
+ writes: FileInfo[]
50
+ localOnly: FileInfo[]
51
+ }
52
+
53
+ // Watcher events
54
+ export type WatcherEventKind = "add" | "change" | "delete"
55
+
56
+ export interface WatcherEvent {
57
+ kind: WatcherEventKind
58
+ relativePath: string
59
+ content?: string
60
+ }
61
+
62
+ // Conflict version data
63
+ export interface ConflictVersionRequest {
64
+ fileName: string
65
+ lastSyncedAt?: number
66
+ }
67
+
68
+ export interface ConflictVersionData {
69
+ fileName: string
70
+ latestRemoteVersionMs?: number
71
+ }
72
+
73
+ // WebSocket messages (incoming from plugin)
74
+ export type IncomingMessage =
75
+ | { type: "handshake"; projectId: string; projectName: string }
76
+ | { type: "request-files" }
77
+ | { type: "file-list"; files: FileInfo[] }
78
+ | { type: "file-change"; fileName: string; content: string }
79
+ | { type: "file-delete"; fileNames: string[]; requireConfirmation?: boolean }
80
+ | { type: "delete-confirmed"; fileNames: string[] }
81
+ | { type: "delete-cancelled"; files: PendingDelete[] }
82
+ | { type: "file-synced"; fileName: string; remoteModifiedAt: number }
83
+ | {
84
+ type: "conflicts-resolved"
85
+ resolution: "local" | "remote"
86
+ }
87
+ | {
88
+ type: "conflict-version-response"
89
+ versions: ConflictVersionData[]
90
+ }
91
+ | { type: "error"; fileName?: string; message: string }
92
+
93
+ // WebSocket messages (outgoing to plugin)
94
+ export type OutgoingMessage =
95
+ | { type: "request-files" }
96
+ | { type: "file-list"; files: FileInfo[] }
97
+ | { type: "file-change"; fileName: string; content: string }
98
+ | {
99
+ type: "file-delete"
100
+ fileNames: string[]
101
+ requireConfirmation?: boolean
102
+ }
103
+ | { type: "conflicts-detected"; conflicts: Conflict[] }
104
+ | {
105
+ type: "conflict-version-request"
106
+ conflicts: ConflictVersionRequest[]
107
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ hashFileContent,
3
+ loadPersistedState,
4
+ savePersistedState,
5
+ type PersistedFileState,
6
+ } from "./state-persistence.js"
7
+
8
+ export interface FileSyncMetadata {
9
+ localHash: string
10
+ lastSyncedHash: string
11
+ lastRemoteTimestamp?: number
12
+ }
13
+
14
+ export class FileMetadataCache {
15
+ private metadata = new Map<string, FileSyncMetadata>()
16
+ private persisted = new Map<string, PersistedFileState>()
17
+ private projectDir: string | null = null
18
+ private initialized = false
19
+ private pendingPersist: Promise<void> | null = null
20
+
21
+ async initialize(projectDir: string): Promise<void> {
22
+ if (this.initialized && this.projectDir === projectDir) {
23
+ return
24
+ }
25
+
26
+ this.projectDir = projectDir
27
+ const loaded = await loadPersistedState(projectDir)
28
+ this.persisted = loaded
29
+ this.metadata = new Map()
30
+
31
+ for (const [fileName, state] of loaded.entries()) {
32
+ this.metadata.set(fileName, {
33
+ localHash: state.contentHash,
34
+ lastSyncedHash: state.contentHash,
35
+ lastRemoteTimestamp: state.timestamp,
36
+ })
37
+ }
38
+
39
+ this.initialized = true
40
+ }
41
+
42
+ get(fileName: string): FileSyncMetadata | undefined {
43
+ return this.metadata.get(fileName)
44
+ }
45
+
46
+ has(fileName: string): boolean {
47
+ return this.metadata.has(fileName)
48
+ }
49
+
50
+ size(): number {
51
+ return this.metadata.size
52
+ }
53
+
54
+ getPersistedState(): Map<string, PersistedFileState> {
55
+ return this.persisted
56
+ }
57
+
58
+ recordRemoteWrite(
59
+ fileName: string,
60
+ content: string,
61
+ remoteModifiedAt: number
62
+ ): void {
63
+ const contentHash = hashFileContent(content)
64
+ this.metadata.set(fileName, {
65
+ localHash: contentHash,
66
+ lastSyncedHash: contentHash,
67
+ lastRemoteTimestamp: remoteModifiedAt,
68
+ })
69
+ this.persisted.set(fileName, {
70
+ contentHash,
71
+ timestamp: remoteModifiedAt,
72
+ })
73
+ this.schedulePersist()
74
+ }
75
+
76
+ recordSyncedSnapshot(
77
+ fileName: string,
78
+ contentHash: string,
79
+ remoteModifiedAt: number
80
+ ): void {
81
+ this.metadata.set(fileName, {
82
+ localHash: contentHash,
83
+ lastSyncedHash: contentHash,
84
+ lastRemoteTimestamp: remoteModifiedAt,
85
+ })
86
+ this.persisted.set(fileName, {
87
+ contentHash,
88
+ timestamp: remoteModifiedAt,
89
+ })
90
+ this.schedulePersist()
91
+ }
92
+
93
+ recordDelete(fileName: string): void {
94
+ this.metadata.delete(fileName)
95
+ this.persisted.delete(fileName)
96
+ this.schedulePersist()
97
+ }
98
+
99
+ async flush(): Promise<void> {
100
+ if (this.pendingPersist) {
101
+ await this.pendingPersist
102
+ }
103
+ }
104
+
105
+ private schedulePersist(): void {
106
+ if (!this.projectDir) {
107
+ return
108
+ }
109
+
110
+ if (!this.pendingPersist) {
111
+ this.pendingPersist = (async () => {
112
+ try {
113
+ await Promise.resolve()
114
+ await savePersistedState(this.projectDir as string, this.persisted)
115
+ } finally {
116
+ this.pendingPersist = null
117
+ }
118
+ })()
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,95 @@
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 hash of file content for comparison
76
+ */
77
+ function hashContent(content: string): string {
78
+ return createHash("sha256").update(content).digest("hex")
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
+ }