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,158 +0,0 @@
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 { debug, 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
- debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`)
59
- return false
60
- }
61
- throw err
62
- }
63
- }
64
-
65
- await sendMessage(socket, {
66
- type: "file-delete",
67
- fileNames: [fileName],
68
- requireConfirmation: false,
69
- })
70
-
71
- return true
72
- }
73
-
74
- /**
75
- * Sends conflicts to the plugin and awaits user resolutions
76
- */
77
- async requestConflictDecisions(
78
- socket: WebSocket | null,
79
- conflicts: Conflict[]
80
- ): Promise<Map<string, "local" | "remote">> {
81
- if (!socket) {
82
- throw new Error("Cannot request conflict decision: plugin not connected")
83
- }
84
-
85
- if (conflicts.length === 0) {
86
- return new Map()
87
- }
88
-
89
- const pending = conflicts.map((conflict) => ({
90
- fileName: conflict.fileName,
91
- promise: this.awaitConfirmation<"local" | "remote">(
92
- `conflict:${conflict.fileName}`,
93
- "conflict resolution"
94
- ),
95
- }))
96
-
97
- await sendMessage(socket, {
98
- type: "conflicts-detected",
99
- conflicts,
100
- })
101
-
102
- try {
103
- const results = await Promise.all(
104
- pending.map(
105
- async ({ fileName, promise }) => [fileName, await promise] as const
106
- )
107
- )
108
-
109
- return new Map(results)
110
- } catch (err) {
111
- if (err instanceof PluginDisconnectedError) {
112
- debug("Plugin disconnected while awaiting conflict decisions")
113
- return new Map()
114
- }
115
- throw err
116
- }
117
- }
118
-
119
- /**
120
- * Generic confirmation awaiter
121
- */
122
- private awaitConfirmation<T>(
123
- actionId: string,
124
- description: string
125
- ): Promise<T> {
126
- return new Promise<T>((resolve, reject) => {
127
- this.pendingActions.set(actionId, { resolve, reject })
128
- debug(`Awaiting ${description}: ${actionId}`)
129
- })
130
- }
131
-
132
- /**
133
- * Handle incoming confirmation response
134
- */
135
- handleConfirmation<T>(actionId: string, value: T): boolean {
136
- const pending = this.pendingActions.get(actionId)
137
- if (!pending) {
138
- debug(`Unexpected confirmation for ${actionId}`)
139
- return false
140
- }
141
-
142
- this.pendingActions.delete(actionId)
143
- pending.resolve(value)
144
- debug(`Confirmed: ${actionId}`)
145
- return true
146
- }
147
-
148
- /**
149
- * Cleanup all pending actions (e.g., on disconnect)
150
- */
151
- cleanup(): void {
152
- for (const [actionId, pending] of this.pendingActions.entries()) {
153
- pending.reject(new PluginDisconnectedError())
154
- debug(`Cancelled pending action: ${actionId}`)
155
- }
156
- this.pendingActions.clear()
157
- }
158
- }
@@ -1,74 +0,0 @@
1
- import { describe, expect, it, vi, afterEach } from "vitest"
2
- import fs from "fs/promises"
3
- import os from "os"
4
- import path from "path"
5
- import { initWatcher } from "./watcher.js"
6
-
7
- const createdWatchers: any[] = []
8
-
9
- vi.mock("chokidar", () => {
10
- const createMockWatcher = () => {
11
- const handlers: Record<string, Array<(filePath: string) => any>> = {
12
- add: [],
13
- change: [],
14
- unlink: [],
15
- }
16
-
17
- return {
18
- on(event: "add" | "change" | "unlink", handler: (file: string) => any) {
19
- handlers[event]?.push(handler)
20
- return this
21
- },
22
- async __emit(event: "add" | "change" | "unlink", filePath: string) {
23
- for (const handler of handlers[event] ?? []) {
24
- await handler(filePath)
25
- }
26
- },
27
- close: vi.fn().mockResolvedValue(undefined),
28
- }
29
- }
30
-
31
- const watch = vi.fn(() => {
32
- const instance = createMockWatcher()
33
- createdWatchers.push(instance)
34
- return instance
35
- })
36
-
37
- return { default: { watch }, watch }
38
- })
39
-
40
- describe("initWatcher", () => {
41
- afterEach(() => {
42
- createdWatchers.length = 0
43
- })
44
-
45
- it("ignores unsupported extensions and sanitizes added files", async () => {
46
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-"))
47
- const events: any[] = []
48
- const watcher = initWatcher(tmpDir)
49
- watcher.on("change", (event) => events.push(event))
50
- const rawWatcher = createdWatchers.at(-1)!
51
-
52
- const unsupportedPath = path.join(tmpDir, "note.txt")
53
- await fs.writeFile(unsupportedPath, "hello", "utf-8")
54
- await rawWatcher.__emit("add", unsupportedPath)
55
- expect(events).toHaveLength(0)
56
-
57
- const rawPath = path.join(tmpDir, "bad name!.tsx")
58
- await fs.writeFile(rawPath, "export const X = 1;", "utf-8")
59
- await rawWatcher.__emit("add", rawPath)
60
-
61
- const addEvent = events.find((e) => e.kind === "add")
62
- expect(addEvent).toBeDefined()
63
- expect(addEvent?.relativePath).toBe("bad_name_.tsx")
64
- expect(addEvent?.content).toContain("export const X")
65
-
66
- const renamedPath = path.join(tmpDir, "bad_name_.tsx")
67
- await expect(fs.readFile(renamedPath, "utf-8")).resolves.toContain(
68
- "export const X"
69
- )
70
-
71
- await watcher.close()
72
- await fs.rm(tmpDir, { recursive: true, force: true })
73
- })
74
- })
@@ -1,110 +0,0 @@
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 { 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
- debug(`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
- debug(`Renamed ${rawRelativePath} -> ${relativePath}`)
66
- effectiveAbsolutePath = newAbsolutePath
67
- } catch (err) {
68
- warn(`Failed to rename ${rawRelativePath}`, err)
69
- }
70
- }
71
-
72
- let content: string | undefined
73
- if (kind !== "delete") {
74
- try {
75
- content = await fs.readFile(effectiveAbsolutePath, "utf-8")
76
- } catch (err) {
77
- debug(`Failed to read file ${relativePath}:`, err)
78
- return
79
- }
80
- }
81
-
82
- const event: WatcherEvent = {
83
- kind,
84
- relativePath,
85
- content,
86
- }
87
-
88
- debug(`Watcher event: ${kind} ${relativePath}`)
89
-
90
- for (const handler of handlers) {
91
- handler(event)
92
- }
93
- }
94
-
95
- watcher.on("add", (filePath) => emitEvent("add", filePath))
96
- watcher.on("change", (filePath) => emitEvent("change", filePath))
97
- watcher.on("unlink", (filePath) => emitEvent("delete", filePath))
98
-
99
- return {
100
- on(event: "change", handler: (event: WatcherEvent) => void): void {
101
- if (event === "change") {
102
- handlers.push(handler)
103
- }
104
- },
105
-
106
- async close(): Promise<void> {
107
- await watcher.close()
108
- },
109
- }
110
- }
package/src/index.ts DELETED
@@ -1,111 +0,0 @@
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, banner, warn } from "./utils/logging.js"
14
- import { getPortFromHash } from "@code-link/shared"
15
- import { getProjectHashFromCwd } from "./utils/project.js"
16
-
17
- const program = new Command()
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
-
27
- program
28
- .name("code-link")
29
- .description("Sync Framer code components to your local filesystem")
30
- .version("0.1.0")
31
- .argument(
32
- "[projectHash]",
33
- "Framer Project ID Hash (auto-detected from package.json if omitted)"
34
- )
35
- .option("-n, --name <name>", "Project name (optional)")
36
- .option("-d, --dir <directory>", "Explicit project directory")
37
- .option("-v, --verbose", "Enable verbose logging")
38
- .option("--log-level <level>", "Set log level (debug, info, warn, error)")
39
- .option(
40
- "--dangerously-auto-delete",
41
- "Automatically delete remote files without confirmation"
42
- )
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
-
60
- // Auto-enable debug in development unless overridden
61
- const isDev = process.env.NODE_ENV === "development"
62
-
63
- if (options.logLevel) {
64
- const levelMap: Record<string, LogLevel> = {
65
- debug: LogLevel.DEBUG,
66
- info: LogLevel.INFO,
67
- warn: LogLevel.WARN,
68
- error: LogLevel.ERROR,
69
- }
70
- const level = levelMap[options.logLevel.toLowerCase()]
71
- if (level !== undefined) {
72
- setLogLevel(level)
73
- }
74
- } else if (options.verbose || isDev) {
75
- setLogLevel(LogLevel.DEBUG)
76
- }
77
-
78
- const port = getPortFromHash(projectHash)
79
-
80
- // Show startup banner
81
- banner("0.1.3", port)
82
-
83
- const config: Config = {
84
- port,
85
- projectHash,
86
- projectDir: null, // Will be set during handshake
87
- filesDir: null, // Will be set during handshake
88
- dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
89
- explicitDir: options.dir,
90
- explicitName: options.name,
91
- }
92
-
93
- if (config.dangerouslyAutoDelete) {
94
- warn(
95
- "Auto-delete mode enabled - files will be deleted without confirmation"
96
- )
97
- }
98
-
99
- try {
100
- await start(config)
101
- } catch (err) {
102
- // Error already logged, exit cleanly
103
- process.exit(1)
104
- }
105
- })
106
-
107
- program.parse()
108
-
109
- // Export for programmatic usage
110
- export { start } from "./controller.js"
111
- export type { Config } from "./types.js"
package/src/types.ts DELETED
@@ -1,112 +0,0 @@
1
- /**
2
- * Core types for the controller-centric CLI architecture
3
- */
4
-
5
- import type { PendingDelete } from "@code-link/shared"
6
-
7
- // Configuration
8
- export interface Config {
9
- port: number
10
- projectHash: string
11
- projectDir: string | null // Set during handshake if not already determined
12
- filesDir: string | null // Set during handshake if not already determined
13
- dangerouslyAutoDelete: boolean
14
- explicitDir?: string // User-provided directory override
15
- explicitName?: string // User-provided name override
16
- }
17
-
18
- // File representations
19
- export interface FileInfo {
20
- name: string
21
- content: string
22
- modifiedAt?: number
23
- }
24
-
25
- export interface LocalFile {
26
- relativePath: string
27
- content: string
28
- modifiedAt?: number
29
- }
30
-
31
- // Conflict detection
32
- // Deletions are represented by null content
33
- // For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null
34
- export interface Conflict {
35
- fileName: 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
40
- localModifiedAt?: number
41
- remoteModifiedAt?: number
42
- lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
43
- /**
44
- * True when the local file still matches the last persisted hash.
45
- * Used for auto-resolution heuristics.
46
- */
47
- localClean?: boolean
48
- }
49
-
50
- export interface ConflictResolution {
51
- conflicts: Conflict[]
52
- writes: FileInfo[]
53
- localOnly: FileInfo[]
54
- unchanged: FileInfo[]
55
- }
56
-
57
- // Watcher events
58
- export type WatcherEventKind = "add" | "change" | "delete"
59
-
60
- export interface WatcherEvent {
61
- kind: WatcherEventKind
62
- relativePath: string
63
- content?: string
64
- }
65
-
66
- // Conflict version data
67
- export interface ConflictVersionRequest {
68
- fileName: string
69
- lastSyncedAt?: number
70
- }
71
-
72
- export interface ConflictVersionData {
73
- fileName: string
74
- latestRemoteVersionMs?: number
75
- }
76
-
77
- // WebSocket messages (incoming from plugin)
78
- export type IncomingMessage =
79
- | { type: "handshake"; projectId: string; projectName: string }
80
- | { type: "request-files" }
81
- | { type: "file-list"; files: FileInfo[] }
82
- | { type: "file-change"; fileName: string; content: string }
83
- | { type: "file-delete"; fileNames: string[]; requireConfirmation?: boolean }
84
- | { type: "delete-confirmed"; fileNames: string[] }
85
- | { type: "delete-cancelled"; files: PendingDelete[] }
86
- | { type: "file-synced"; fileName: string; remoteModifiedAt: number }
87
- | {
88
- type: "conflicts-resolved"
89
- resolution: "local" | "remote"
90
- }
91
- | {
92
- type: "conflict-version-response"
93
- versions: ConflictVersionData[]
94
- }
95
- | { type: "error"; fileName?: string; message: string }
96
-
97
- // WebSocket messages (outgoing to plugin)
98
- export type OutgoingMessage =
99
- | { type: "request-files" }
100
- | { type: "file-list"; files: FileInfo[] }
101
- | { type: "file-change"; fileName: string; content: string }
102
- | {
103
- type: "file-delete"
104
- fileNames: string[]
105
- requireConfirmation?: boolean
106
- }
107
- | { type: "conflicts-detected"; conflicts: Conflict[] }
108
- | {
109
- type: "conflict-version-request"
110
- conflicts: ConflictVersionRequest[]
111
- }
112
- | { type: "sync-complete" }
@@ -1,121 +0,0 @@
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
- }