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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ plugin.zip
!.yarn/plugins
!.yarn/sdks
!.yarn/versions


# Code Link CLI — ignore folders (Untitled, etc.) for testing
packages/code-link-cli/*/
!packages/code-link-cli/src/
!packages/code-link-cli/skills/
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.15.0",
"version": "0.17.0",
"description": "CLI tool for syncing Framer code components - controller-centric architecture",
"main": "dist/index.mjs",
"type": "module",
Expand Down
50 changes: 36 additions & 14 deletions packages/code-link-cli/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff
return { state, effects }
}

effects.push(log("debug", `Received file list: ${event.files.length} files`))
effects.push(log("debug", `Received file list: ${pluralize(event.files.length, "file")}`))

// During initial file list, detect conflicts between remote snapshot and local files
effects.push({
Expand Down Expand Up @@ -304,7 +304,11 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff

// Apply safe writes
if (safeWrites.length > 0) {
effects.push(log("debug", `Applying ${safeWrites.length} safe writes`), {
effects.push(log("debug", `Applying ${safeWrites.length} safe writes`))
if (wasRecentlyDisconnected()) {
effects.push(log("success", `Applied ${pluralize(safeWrites.length, "file")} during sync`))
}
effects.push({
type: "WRITE_FILES",
files: safeWrites,
silent: true,
Expand All @@ -313,7 +317,7 @@ function transition(state: SyncState, event: SyncEvent): { state: SyncState; eff

// Upload local-only files
if (localOnly.length > 0) {
effects.push(log("debug", `Uploading ${localOnly.length} local-only files`))
effects.push(log("debug", `Uploading ${pluralize(localOnly.length, "local-only file")}`))
for (const file of localOnly) {
effects.push({
type: "SEND_MESSAGE",
Expand Down Expand Up @@ -683,14 +687,18 @@ async function executeEffect(
if (!config.projectDir) {
const projectName = config.explicitName ?? effect.projectInfo.projectName

const directoryInfo = await findOrCreateProjectDirectory(
config.projectHash,
const directoryInfo = await findOrCreateProjectDirectory({
projectHash: config.projectHash,
projectName,
config.explicitDirectory
)
explicitDirectory: config.explicitDirectory,
})
config.projectDir = directoryInfo.directory
config.projectDirCreated = directoryInfo.created

if (directoryInfo.nameCollision) {
warn(`Folder ${projectName} already exists`)
}

// May allow customization of file directory in the future
config.filesDir = `${config.projectDir}/files`
debug(`Files directory: ${config.filesDir}`)
Expand All @@ -702,7 +710,7 @@ async function executeEffect(
case "LOAD_PERSISTED_STATE": {
if (config.projectDir) {
await fileMetadataCache.initialize(config.projectDir)
debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`)
debug(`Loaded persisted metadata for ${pluralize(fileMetadataCache.size(), "file")}`)
}
return []
}
Expand Down Expand Up @@ -918,6 +926,7 @@ async function executeEffect(
for (const fileName of confirmedFiles) {
hashTracker.forget(fileName)
fileMetadataCache.recordDelete(fileName)
fileDelete(fileName)
}

if (confirmedFiles.length > 0 && syncState.socket) {
Expand Down Expand Up @@ -950,7 +959,7 @@ async function executeEffect(
// Only show reconnect message if we actually showed the disconnect notice
if (didShowDisconnect()) {
success(
`Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
`Reconnected, synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
)
status("Watching for changes...")
}
Expand All @@ -968,14 +977,14 @@ async function executeEffect(
success(`Syncing to ${relativeDirectory} folder`)
}
} else if (relativeDirectory && config.projectDirCreated) {
success(`Synced into ${relativeDirectory} (${effect.updatedCount} files added)`)
success(`Created ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} added)`)
} else if (relativeDirectory) {
success(
`Synced into ${relativeDirectory} (${effect.updatedCount} files updated, ${effect.unchangedCount} unchanged)`
`Synced into ${relativeDirectory} (${pluralize(effect.updatedCount, "file")} updated, ${effect.unchangedCount} unchanged)`
)
} else {
success(
`Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
`Synced ${pluralize(effect.totalCount, "file")} (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)`
)
}
// Git init after first sync so initial commit includes all synced files
Expand Down Expand Up @@ -1073,6 +1082,15 @@ export async function start(config: Config): Promise<void> {
void (async () => {
cancelDisconnectMessage()

if (syncState.mode !== "disconnected") {
if (syncState.socket === client) {
debug(`Ignoring duplicate handshake from active socket in ${syncState.mode} mode`)
return
}
debug(`New handshake received in ${syncState.mode} mode, resetting sync state`)
await processEvent({ type: "DISCONNECT" })
}

// Only show "Connected" on initial connection, not reconnects
// Reconnect confirmation happens in SYNC_COMPLETE
const wasDisconnected = wasRecentlyDisconnected()
Expand Down Expand Up @@ -1120,7 +1138,7 @@ export async function start(config: Config): Promise<void> {
break

case "file-list": {
debug(`Received file list: ${message.files.length} files`)
debug(`Received file list: ${pluralize(message.files.length, "file")}`)
event = { type: "REMOTE_FILE_LIST", files: message.files }
break
}
Expand Down Expand Up @@ -1222,7 +1240,11 @@ export async function start(config: Config): Promise<void> {
})()
})

connection.on("disconnect", () => {
connection.on("disconnect", (client: WebSocket) => {
if (syncState.socket !== client) {
debug("[STATE] Ignoring disconnect from stale socket")
return
}
// Schedule disconnect message with delay - if reconnect happens quickly, we skip it
scheduleDisconnectMessage(() => {
status("Disconnected, waiting to reconnect...")
Expand Down
28 changes: 24 additions & 4 deletions packages/code-link-cli/src/helpers/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { debug, error } from "../utils/logging.ts"
export interface ConnectionCallbacks {
onHandshake: (client: WebSocket, message: { projectId: string; projectName: string }) => void
onMessage: (message: PluginToCliMessage) => void
onDisconnect: () => void
onDisconnect: (client: WebSocket) => void
onError: (error: Error) => void
}

Expand Down Expand Up @@ -61,6 +61,7 @@ export function initConnection(port: number): Promise<Connection> {
wss.on("listening", () => {
isReady = true
debug(`WebSocket server listening on port ${port}`)
let activeClient: WebSocket | null = null

wss.on("connection", (ws: WebSocket) => {
const connId = ++connectionId
Expand All @@ -71,13 +72,27 @@ export function initConnection(port: number): Promise<Connection> {
try {
const message = JSON.parse(data.toString()) as PluginToCliMessage

// Special handling for handshake
if (message.type === "handshake") {
debug(`Received handshake (conn ${connId})`)
handshakeReceived = true
const previousActiveClient = activeClient
activeClient = ws

// Make this the active client, ignore stale close events from the old one.
if (previousActiveClient && previousActiveClient !== activeClient) {
debug(`Replacing active client with conn ${connId}`)
if (
previousActiveClient.readyState === READY_STATE.OPEN ||
previousActiveClient.readyState === READY_STATE.CONNECTING
) {
previousActiveClient.close()
}
}
handlers.onHandshake?.(ws, message)
} else if (handshakeReceived) {
} else if (handshakeReceived && activeClient === ws) {
handlers.onMessage?.(message)
} else if (handshakeReceived) {
debug(`Ignoring ${message.type} from stale client (conn ${connId})`)
} else {
// Ignore messages before handshake - plugin will send full snapshot after
debug(`Ignoring ${message.type} before handshake (conn ${connId})`)
Expand All @@ -89,7 +104,12 @@ export function initConnection(port: number): Promise<Connection> {

ws.on("close", (code, reason) => {
debug(`Client disconnected (code: ${code}, reason: ${reason.toString()})`)
handlers.onDisconnect?.()
if (activeClient === ws) {
activeClient = null
handlers.onDisconnect?.(ws)
} else {
debug(`Ignoring disconnect from stale client (conn ${connId})`)
}
})

ws.on("error", err => {
Expand Down
6 changes: 3 additions & 3 deletions packages/code-link-cli/src/helpers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Controller decides WHEN to call these, but never computes conflicts itself.
*/

import { fileKeyForLookup, normalizePath, sanitizeFilePath } from "@code-link/shared"
import { fileKeyForLookup, normalizePath, pluralize, sanitizeFilePath } from "@code-link/shared"
import fs from "fs/promises"
import path from "path"
import type { Conflict, ConflictResolution, ConflictVersionData, FileInfo } from "../types.ts"
Expand Down Expand Up @@ -96,7 +96,7 @@ export async function detectConflicts(
// Persisted state keys are normalized to lowercase for case-insensitive lookup
const getPersistedState = (fileName: string) => persistedState?.get(fileKeyForLookup(fileName))

debug(`Detecting conflicts for ${String(remoteFiles.length)} remote files`)
debug(`Detecting conflicts for ${pluralize(remoteFiles.length, "remote file")}`)

// Build a snapshot of all local files (keyed by lowercase for case-insensitive matching)
const localFiles = await listFiles(filesDir)
Expand Down Expand Up @@ -323,7 +323,7 @@ export async function writeRemoteFiles(
hashTracker: HashTracker,
installer?: { process: (fileName: string, content: string) => void }
): Promise<void> {
debug(`Writing ${files.length} remote files`)
debug(`Writing ${pluralize(files.length, "remote file")}`)

for (const file of files) {
try {
Expand Down
144 changes: 144 additions & 0 deletions packages/code-link-cli/src/utils/project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { shortProjectHash } from "@code-link/shared"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { findOrCreateProjectDirectory, toDirectoryName, toPackageName } from "./project.ts"

describe("toPackageName", () => {
it("lowercases and replaces invalid chars", () => {
expect(toPackageName("My Project")).toBe("my-project")
expect(toPackageName("Hello World!")).toBe("hello-world")
})
})

describe("toDirectoryName", () => {
it("replaces invalid chars preserving case and spaces", () => {
expect(toDirectoryName("My Project")).toBe("My Project")
expect(toDirectoryName("Hello World!")).toBe("Hello World")
})
})

describe("findOrCreateProjectDirectory", () => {
let tmpDir: string

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-test-"))
})

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})

it("uses the project name as directory name", async () => {
const result = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
baseDirectory: tmpDir,
})

expect(result.created).toBe(true)
expect(path.basename(result.directory)).toBe("My Project")

const pkg = JSON.parse(await fs.readFile(path.join(result.directory, "package.json"), "utf-8"))
expect(pkg.shortProjectHash).toBe(shortProjectHash("hashA"))
expect(pkg.framerProjectName).toBe("My Project")
})

it("reuses existing directory when hash matches", async () => {
const first = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
baseDirectory: tmpDir,
})
const second = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
baseDirectory: tmpDir,
})

expect(first.created).toBe(true)
expect(second.created).toBe(false)
expect(second.directory).toBe(first.directory)
})

it("creates separate directories for same-named projects with different hashes", async () => {
const projectA = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
baseDirectory: tmpDir,
})
const projectB = await findOrCreateProjectDirectory({
projectHash: "hashB",
projectName: "My Project",
baseDirectory: tmpDir,
})

// First gets the bare name, second gets name-hash
expect(path.basename(projectA.directory)).toBe("My Project")
expect(path.basename(projectB.directory)).toBe(`My Project-${shortProjectHash("hashB")}`)

// Must be distinct directories
expect(projectA.directory).not.toBe(projectB.directory)
expect(projectA.created).toBe(true)
expect(projectB.created).toBe(true)

// Each has correct hash in package.json
const pkgA = JSON.parse(await fs.readFile(path.join(projectA.directory, "package.json"), "utf-8"))
const pkgB = JSON.parse(await fs.readFile(path.join(projectB.directory, "package.json"), "utf-8"))
expect(pkgA.shortProjectHash).toBe(shortProjectHash("hashA"))
expect(pkgB.shortProjectHash).toBe(shortProjectHash("hashB"))
})

it("does not overwrite first project's package.json when second has same name", async () => {
const projectA = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
baseDirectory: tmpDir,
})

// Write a file into project A to simulate synced state
await fs.writeFile(path.join(projectA.directory, "files", "Component.tsx"), "export default () => <div/>")

const projectB = await findOrCreateProjectDirectory({
projectHash: "hashB",
projectName: "My Project",
baseDirectory: tmpDir,
})

// Project A's package.json must still have its own hash
const pkgA = JSON.parse(await fs.readFile(path.join(projectA.directory, "package.json"), "utf-8"))
expect(pkgA.shortProjectHash).toBe(shortProjectHash("hashA"))

// Project A's files must still exist
const content = await fs.readFile(path.join(projectA.directory, "files", "Component.tsx"), "utf-8")
expect(content).toBe("export default () => <div/>")

// Project B's directory is separate and empty
const filesB = await fs.readdir(path.join(projectB.directory, "files"))
expect(filesB).toHaveLength(0)
})

it("uses explicit directory when provided", async () => {
const explicitDir = path.join(tmpDir, "custom-dir")
const result = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "My Project",
explicitDirectory: explicitDir,
})

expect(result.directory).toBe(explicitDir)
expect(result.created).toBe(false)
})

it("falls back to project-[hash] when project name is empty after sanitization", async () => {
const result = await findOrCreateProjectDirectory({
projectHash: "hashA",
projectName: "!!!",
baseDirectory: tmpDir,
})
const shortId = shortProjectHash("hashA")

expect(path.basename(result.directory)).toBe(`project-${shortId}`)
})
})
Loading
Loading