Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/code-link-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "framer-code-link",
"version": "0.7.0",
"version": "0.11.0",
"description": "CLI tool for syncing Framer code components - controller-centric architecture",
"main": "dist/index.mjs",
"type": "module",
Expand Down
53 changes: 38 additions & 15 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { CliToPluginMessage, PluginToCliMessage } from "@code-link/shared"
import { pluralize, shortProjectHash } from "@code-link/shared"
import fs from "fs/promises"
import path from "path"
import type { WebSocket } from "ws"
import { initConnection, sendMessage } from "./helpers/connection.ts"
import {
Expand Down Expand Up @@ -42,7 +43,7 @@ import {
warn,
wasRecentlyDisconnected,
} from "./utils/logging.ts"
import { findOrCreateProjectDir } from "./utils/project.ts"
import { findOrCreateProjectDirectory } from "./utils/project.ts"
import { hashFileContent } from "./utils/state-persistence.ts"

/**
Expand Down Expand Up @@ -681,7 +682,13 @@ async function executeEffect(
if (!config.projectDir) {
const projectName = config.explicitName ?? effect.projectInfo.projectName

config.projectDir = await findOrCreateProjectDir(config.projectHash, projectName, config.explicitDir)
const directoryInfo = await findOrCreateProjectDirectory(
config.projectHash,
projectName,
config.explicitDirectory
)
config.projectDir = directoryInfo.directory
config.projectDirCreated = directoryInfo.created

// May allow customization of file directory in the future
config.filesDir = `${config.projectDir}/files`
Expand Down Expand Up @@ -950,9 +957,26 @@ async function executeEffect(
return []
}

success(
`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
)
const relative = config.projectDir ? path.relative(process.cwd(), config.projectDir) : null
const relativeDirectory = relative != null ? (relative ? "./" + relative : ".") : null

if (effect.totalCount === 0 && relativeDirectory) {
if (config.projectDirCreated) {
success(`Created ${relativeDirectory} folder`)
} else {
success(`Syncing to ${relativeDirectory} folder`)
}
} else if (relativeDirectory && config.projectDirCreated) {
success(`Synced into ${relativeDirectory} (${effect.updatedCount} files added)`)
} else if (relativeDirectory) {
success(
`Synced into ${relativeDirectory} (${effect.updatedCount} files updated, ${effect.unchangedCount} unchanged)`
)
} else {
success(
`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
)
}
status("Watching for changes...")
return []
}
Expand Down Expand Up @@ -1041,6 +1065,15 @@ export async function start(config: Config): Promise<void> {
}

void (async () => {
cancelDisconnectMessage()

// Only show "Connected" on initial connection, not reconnects
// Reconnect confirmation happens in SYNC_COMPLETE
const wasDisconnected = wasRecentlyDisconnected()
if (!wasDisconnected && !didShowDisconnect()) {
success(`Connected to ${message.projectName}`)
}

// Process handshake through state machine
await processEvent({
type: "HANDSHAKE",
Expand All @@ -1061,16 +1094,6 @@ export async function start(config: Config): Promise<void> {
// Start file watcher now that we have a directory
startWatcher()
}

// Cancel any pending disconnect message (fast reconnect)
cancelDisconnectMessage()

// Only show "Connected" on initial connection, not reconnects
// Reconnect confirmation happens in SYNC_COMPLETE
const wasDisconnected = wasRecentlyDisconnected()
if (!wasDisconnected && !didShowDisconnect()) {
success(`Connected to ${message.projectName}`)
}
})()
})

Expand Down
2 changes: 1 addition & 1 deletion packages/code-link-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ program
filesDir: null, // Will be set during handshake
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
allowUnsupportedNpm: options.unsupportedNpm ?? false,
explicitDir: options.dir,
explicitDirectory: options.dir,
explicitName: options.name,
}

Expand Down
3 changes: 2 additions & 1 deletion packages/code-link-cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export interface Config {
filesDir: string | null // Set during handshake , always projectDir/files
dangerouslyAutoDelete: boolean
allowUnsupportedNpm: boolean // Allow type acquisition for unsupported npm packages
explicitDir?: string // User-provided directory override
explicitDirectory?: string // User-provided directory override
explicitName?: string // User-provided name override
projectDirCreated?: boolean // Whether the project directory was newly created
}

// Local file representation (CLI-specific)
Expand Down
42 changes: 21 additions & 21 deletions packages/code-link-cli/src/utils/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function toPackageName(name: string): string {
.replace(/-+/g, "-")
}

export function toDirName(name: string): string {
export function toDirectoryName(name: string): string {
return name
.replace(/[^a-zA-Z0-9- ]/g, "-")
.replace(/^[-\s]+|[-\s]+$/g, "")
Expand All @@ -36,57 +36,57 @@ export async function getProjectHashFromCwd(): Promise<string | null> {
}
}

export async function findOrCreateProjectDir(
export async function findOrCreateProjectDirectory(
projectHash: string,
projectName?: string,
explicitDir?: string
): Promise<string> {
if (explicitDir) {
const resolved = path.resolve(explicitDir)
explicitDirectory?: string
): Promise<{ directory: string; created: boolean }> {
if (explicitDirectory) {
const resolved = path.resolve(explicitDirectory)
await fs.mkdir(path.join(resolved, "files"), { recursive: true })
return resolved
return { directory: resolved, created: false }
}

const cwd = process.cwd()
const existing = await findExistingProjectDir(cwd, projectHash)
const existing = await findExistingProjectDirectory(cwd, projectHash)
if (existing) {
return existing
return { directory: existing, created: false }
}

if (!projectName) {
throw new Error("Failed to get Project name. Pass --name <project name>.")
}

const dirName = toDirName(projectName)
const directoryName = toDirectoryName(projectName)
const pkgName = toPackageName(projectName)
const shortId = shortProjectHash(projectHash)
const projectDir = path.join(cwd, dirName || shortId)
const projectDirectory = path.join(cwd, directoryName || shortId)

await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
await fs.mkdir(path.join(projectDirectory, "files"), { recursive: true })
const pkg: PackageJson = {
name: pkgName || shortId,
version: "1.0.0",
private: true,
shortProjectHash: shortId,
framerProjectName: projectName,
}
await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2))
await fs.writeFile(path.join(projectDirectory, "package.json"), JSON.stringify(pkg, null, 2))

return projectDir
return { directory: projectDirectory, created: true }
}

async function findExistingProjectDir(baseDir: string, projectHash: string): Promise<string | null> {
const candidate = path.join(baseDir, "package.json")
async function findExistingProjectDirectory(baseDirectory: string, projectHash: string): Promise<string | null> {
const candidate = path.join(baseDirectory, "package.json")
if (await matchesProject(candidate, projectHash)) {
return baseDir
return baseDirectory
}

const entries = await fs.readdir(baseDir, { withFileTypes: true })
const entries = await fs.readdir(baseDirectory, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = path.join(baseDir, entry.name)
if (await matchesProject(path.join(dir, "package.json"), projectHash)) {
return dir
const directory = path.join(baseDirectory, entry.name)
if (await matchesProject(path.join(directory, "package.json"), projectHash)) {
return directory
}
}

Expand Down
31 changes: 24 additions & 7 deletions plugins/code-link/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
# Framer Code Link

Plugin that syncs code files between Framer and your local filesystem via the Code Link CLI.
Two-way sync between Framer code components and your local filesystem.

**By:** @huntercaron
**By:** [@huntercaron](https://github.com/huntercaron)

![Code Link Image](../../assets/code-link.png)
<img src="../../assets/code-link.png" width="600" alt="Code Link">

## Usage
## Features

1. Open the Plugin in Framer
2. Copy the CLI command shown in the Plugin
3. Run the command in your terminal to start syncing
- **Instant two-way sync** — Edits in locally instantly appear in Framer, and vice-versa
- **Automatic types** — TypeScript types for `framer`, `framer-motion`, `react` are automatically installed
- **Smart conflict resolution** — Auto-resolves when safe, prompts you to choose when both sides change
- **Zero config** — Creates config files on first run; re-run with just `npx framer-code-link`
- **AI skill** — Installs Framer component best-practices skill for Cursor, Claude, etc

## Quick Start

1. Open the **Code Link** Plugin in your Framer project
2. Copy the CLI command from the Plugin, eg. `npx framer-code-link (hash)`
3. Paste and run the command in your terminal
4. Edit files in `{project}/files/` — changes sync to Framer

## CLI Options

| Flag | Description |
| --- | --- |
| `-n, --name <name>` | Project name for the created directory |
| `-d, --dir <directory>` | Target project directory |
| `-v, --verbose` | Enable debug logging |
Loading