From 271457458cdb881f84feb5fe26d5ac0f8307dd65 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 12:21:26 +0100 Subject: [PATCH 01/14] add cli inital code --- cli-test/Comp.tsx | 7 + cli-test/Comp2.tsx | 7 + cli/.env.example | 4 + cli/.gitignore | 3 + cli/lib/file-scanner.ts | 82 ++++++ cli/lib/framer-push.ts | 173 +++++++++++ cli/lib/transform.ts | 150 ++++++++++ cli/package.json | 20 ++ cli/pnpm-lock.yaml | 406 ++++++++++++++++++++++++++ cli/push.ts | 202 +++++++++++++ cli/tsconfig.json | 14 + src/pages/upload/lib/config-loader.ts | 24 +- 12 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 cli-test/Comp.tsx create mode 100644 cli-test/Comp2.tsx create mode 100644 cli/.env.example create mode 100644 cli/.gitignore create mode 100644 cli/lib/file-scanner.ts create mode 100644 cli/lib/framer-push.ts create mode 100644 cli/lib/transform.ts create mode 100644 cli/package.json create mode 100644 cli/pnpm-lock.yaml create mode 100644 cli/push.ts create mode 100644 cli/tsconfig.json diff --git a/cli-test/Comp.tsx b/cli-test/Comp.tsx new file mode 100644 index 0000000..995576f --- /dev/null +++ b/cli-test/Comp.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function Comp() { + return ( +
Comp
+ ) +} diff --git a/cli-test/Comp2.tsx b/cli-test/Comp2.tsx new file mode 100644 index 0000000..53f499a --- /dev/null +++ b/cli-test/Comp2.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function Comp2() { + return ( +
Comp2
+ ) +} diff --git a/cli/.env.example b/cli/.env.example new file mode 100644 index 0000000..5c2237f --- /dev/null +++ b/cli/.env.example @@ -0,0 +1,4 @@ +# Framer project URL for staging components +# Get this from your Framer project settings +FRAMER_PROJECT_URL=https://framer.com/projects/Your-Project--xxxxxxxxxxxxxx +FRAMER_API_KEY=xxx \ No newline at end of file diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..c7511cf --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +.env +last-push.txt +node_modules/ diff --git a/cli/lib/file-scanner.ts b/cli/lib/file-scanner.ts new file mode 100644 index 0000000..478e817 --- /dev/null +++ b/cli/lib/file-scanner.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface ScannedFile { + absolutePath: string; + relativePath: string; // relative to frameship-components folder + framerPath: string; // path in Framer (same as relativePath) + mtime: number; // modification time in ms +} + +export interface ScanResult { + files: ScannedFile[]; + changedFiles: ScannedFile[]; +} + +const COMPONENTS_DIR = path.resolve( + import.meta.dirname, + "../../cli-test" +); + +export function scanTsxFiles(ignoredFiles: string[] = []): ScannedFile[] { + const files: ScannedFile[] = []; + const ignoredSet = new Set( + ignoredFiles.map((f) => f.replace(/^\.\//, "").replace(/\\/g, "/")) + ); + + function scanDir(dir: string, relativeBase: string = "") { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + scanDir(fullPath, path.join(relativeBase, entry.name)); + } else if (entry.isFile() && entry.name.endsWith(".tsx")) { + const relativePath = path + .join(relativeBase, entry.name) + .replace(/\\/g, "/"); + + // Skip ignored files + if (ignoredSet.has(relativePath)) { + continue; + } + + const stat = fs.statSync(fullPath); + files.push({ + absolutePath: fullPath, + relativePath, + framerPath: relativePath, + mtime: stat.mtimeMs, + }); + } + } + } + + scanDir(COMPONENTS_DIR); + return files; +} + +export function filterChangedFiles( + files: ScannedFile[], + lastPushTime: number | null +): ScannedFile[] { + if (lastPushTime === null) { + return files; // First push, all files are "changed" + } + return files.filter((f) => f.mtime > lastPushTime); +} + +export function readLastPushTime(filePath: string): number | null { + try { + const content = fs.readFileSync(filePath, "utf-8").trim(); + const timestamp = parseInt(content, 10); + return isNaN(timestamp) ? null : timestamp; + } catch { + return null; + } +} + +export function saveLastPushTime(filePath: string, timestamp: number): void { + fs.writeFileSync(filePath, timestamp.toString(), "utf-8"); +} diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts new file mode 100644 index 0000000..2a4c9e8 --- /dev/null +++ b/cli/lib/framer-push.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import type { ScannedFile } from "./file-scanner.ts"; +import { transformContent, type ImportReplacementRule } from "./transform.ts"; + +const DUMMY_CONTENT = `export default function Test() { return
Test
}`; +const CONCURRENCY = 10; // Max parallel requests + +export interface PushResult { + created: string[]; + updated: string[]; + errors: Array<{ path: string; error: string }>; +} + +interface CodeFile { + id: string; + path: string; + setFileContent(code: string): Promise; +} + +interface Framer { + getCodeFiles(): Promise; + createCodeFile(name: string, code: string): Promise; + disconnect(): Promise; +} + +async function runWithConcurrency( + items: T[], + fn: (item: T) => Promise, + limit: number +): Promise { + const results: R[] = []; + let index = 0; + + async function worker() { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + } + + const workers = Array(Math.min(limit, items.length)) + .fill(null) + .map(() => worker()); + await Promise.all(workers); + return results; +} + +export async function pushFiles( + projectUrl: string, + files: ScannedFile[], + importRules: ImportReplacementRule[], + onProgress: (message: string) => void +): Promise { + const result: PushResult = { created: [], updated: [], errors: [] }; + + onProgress("Connecting to Framer..."); + const { connect } = await import("framer-api"); + const framer: Framer = await connect(projectUrl); + + try { + onProgress("Fetching existing code files..."); + const existingFiles = await framer.getCodeFiles(); + const existingFileMap = new Map(); + for (const file of existingFiles) { + existingFileMap.set(file.path, file); + } + + // Separate new files vs existing files + const newFiles: ScannedFile[] = []; + const updateFiles: Array<{ file: ScannedFile; existing: CodeFile }> = []; + + for (const file of files) { + const existing = existingFileMap.get(file.framerPath); + if (existing) { + updateFiles.push({ file, existing }); + } else { + newFiles.push(file); + } + } + + // Phase 1: Create new files with dummy content (parallel) + if (newFiles.length > 0) { + onProgress(`Creating ${newFiles.length} new files...`); + const createdFiles = await runWithConcurrency( + newFiles, + async (file) => { + try { + const created = await framer.createCodeFile(file.framerPath, DUMMY_CONTENT); + onProgress(` Created: ${file.framerPath}`); + return { file, created, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + onProgress(` Error creating ${file.framerPath}: ${msg}`); + return { file, created: null, error: msg }; + } + }, + CONCURRENCY + ); + + // Phase 2: Update new files with real content (parallel) + const successfulCreates = createdFiles.filter((r) => r.created !== null); + if (successfulCreates.length > 0) { + onProgress(`Updating ${successfulCreates.length} new files with content...`); + await runWithConcurrency( + successfulCreates, + async ({ file, created }) => { + try { + const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); + const transformed = transformContent(rawContent, importRules, file.framerPath); + await created!.setFileContent(transformed); + result.created.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Timeout errors mean the file was created successfully, just treat as success + if (msg.includes("waitForComponentLoader timeout")) { + result.created.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } else { + result.errors.push({ path: file.framerPath, error: msg }); + onProgress(` Error updating ${file.framerPath}: ${msg}`); + } + } + }, + CONCURRENCY + ); + } + + // Record creation errors + for (const { file, error } of createdFiles) { + if (error) result.errors.push({ path: file.framerPath, error }); + } + } + + // Phase 3: Update existing files (parallel) + if (updateFiles.length > 0) { + onProgress(`Updating ${updateFiles.length} existing files...`); + await runWithConcurrency( + updateFiles, + async ({ file, existing }) => { + try { + const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); + const transformed = transformContent(rawContent, importRules, file.framerPath); + await existing.setFileContent(transformed); + result.updated.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Timeout errors mean the file was updated successfully, just treat as success + if (msg.includes("waitForComponentLoader timeout")) { + result.updated.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } else { + result.errors.push({ path: file.framerPath, error: msg }); + onProgress(` Error: ${file.framerPath}: ${msg}`); + } + } + }, + CONCURRENCY + ); + } + } finally { + onProgress("Disconnecting from Framer..."); + try { + await framer.disconnect(); + onProgress("Disconnected."); + } catch (err) { + onProgress(`Disconnect error (ignored): ${err}`); + } + } + + return result; +} diff --git a/cli/lib/transform.ts b/cli/lib/transform.ts new file mode 100644 index 0000000..a7a1a9e --- /dev/null +++ b/cli/lib/transform.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface ImportReplacementRule { + find: string; + replace: string; +} + +export interface CodeSyncConfig { + version: number; + importReplacements: ImportReplacementRule[]; + ignoredFiles: string[]; +} + +const CONFIG_PATH = path.resolve( + import.meta.dirname, + "../../../framer-components/src/frameship-components/framer-code-sync.config.json" +); + +export function loadConfig(): CodeSyncConfig { + try { + const content = fs.readFileSync(CONFIG_PATH, "utf-8"); + return JSON.parse(content); + } catch { + return { version: 1, importReplacements: [], ignoredFiles: [] }; + } +} + +export function transformContent( + content: string, + rules: ImportReplacementRule[], + framerPath: string +): string { + let output = content; + output = applyImportReplacements(output, rules, framerPath); + output = ensureTsxExtensions(output); + return output; +} + +function applyImportReplacements( + content: string, + rules: ImportReplacementRule[], + framerPath: string +): string { + if (!rules.length) return content; + + const fromDir = getDirname(normalizePath(framerPath)); + let output = content; + + for (const rule of rules) { + const replaceValue = rule.replace; + let finalReplace: string; + + // If replace value is a URL, use as-is + if ( + replaceValue.startsWith("http://") || + replaceValue.startsWith("https://") + ) { + finalReplace = replaceValue; + } else { + // For local paths, calculate relative path + const targetRootPath = stripLeadingDotSlash(normalizePath(replaceValue)); + finalReplace = getRelativePath(fromDir, targetRootPath); + } + + output = replaceImportSpecifier(output, rule.find, finalReplace); + } + return output; +} + +function normalizePath(p: string): string { + return p.replace(/\\/g, "/").replace(/\/+/, "/"); +} + +function stripLeadingDotSlash(p: string): string { + return p.startsWith("./") ? p.slice(2) : p.startsWith(".\\") ? p.slice(2) : p; +} + +function getDirname(p: string): string { + const idx = p.lastIndexOf("/"); + return idx === -1 ? "" : p.slice(0, idx); +} + +function getRelativePath(fromDir: string, toPath: string): string { + const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : []; + const toParts = toPath.split("/").filter(Boolean); + + let i = 0; + while ( + i < fromParts.length && + i < toParts.length && + fromParts[i] === toParts[i] + ) { + i++; + } + + const upSegments = fromParts.length - i; + const downParts = toParts.slice(i); + + const up = upSegments > 0 ? Array(upSegments).fill("..").join("/") : ""; + const down = downParts.join("/"); + + let rel = up && down ? `${up}/${down}` : up || down; + if (!rel.startsWith("../") && !rel.startsWith("./")) { + rel = `./${rel}`; + } + return rel || "./"; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function replaceImportSpecifier( + content: string, + searchSpecifier: string, + replacement: string +): string { + const esc = escapeRegExp(searchSpecifier); + + // Pattern 1: import {...} from 'specifier' + const fromPattern = new RegExp(`from\\s+(["'])${esc}\\1`, "g"); + content = content.replace( + fromPattern, + (_m, quote: string) => `from ${quote}${replacement}${quote}` + ); + + // Pattern 2: side-effect import: import 'specifier' + const sePattern = new RegExp(`(^|[^\\w])import\\s+(["'])${esc}\\2`, "g"); + content = content.replace( + sePattern, + (_m, prefix: string, quote: string) => + `${prefix}import ${quote}${replacement}${quote}` + ); + + return content; +} + +function ensureTsxExtensions(content: string): string { + // Match import statements with relative paths (starting with . or ..) + const importPattern = /(from\s+|import\s+)(["'])(\.\.[^"']*|\.\/[^"']*)\2/g; + + return content.replace(importPattern, (match, prefix, quote, importPath) => { + // Skip if already has extension + if (/\.(tsx|ts|jsx|js|css)$/.test(importPath)) { + return match; + } + return `${prefix}${quote}${importPath}.tsx${quote}`; + }); +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..f0051c6 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "framer-components-staging-push", + "version": "1.0.0", + "type": "module", + "scripts": { + "push": "tsx push.ts", + "push:force": "tsx push.ts --force", + "push:yes": "tsx push.ts --yes" + }, + "dependencies": { + "framer-api": "0.0.1-beta.4", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "dotenv": "^16.4.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } +} diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml new file mode 100644 index 0000000..559285a --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,406 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + framer-api: + specifier: 0.0.1-beta.4 + version: 0.0.1-beta.4 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + dotenv: + specifier: ^16.4.0 + version: 16.6.1 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.19.7': + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + + devalue@5.6.2: + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + framer-api@0.0.1-beta.4: + resolution: {integrity: sha512-3CbzdemiLkc4SqzhqQ4HdhbWo9+Onu3NUZn1MGaddr00OuWfETHFfasW+e8StrtUecFtLqRFZ/xZh7khf7rciA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@types/node@22.19.7': + dependencies: + undici-types: 6.21.0 + + devalue@5.6.2: {} + + dotenv@16.6.1: {} + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + framer-api@0.0.1-beta.4: + dependencies: + devalue: 5.6.2 + unenv: 2.0.0-rc.24 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + ws@8.19.0: {} diff --git a/cli/push.ts b/cli/push.ts new file mode 100644 index 0000000..43c1f08 --- /dev/null +++ b/cli/push.ts @@ -0,0 +1,202 @@ +import "dotenv/config"; +import path from "node:path"; +import readline from "node:readline"; +import pc from "picocolors"; +import { + scanTsxFiles, + filterChangedFiles, + readLastPushTime, + saveLastPushTime, + type ScannedFile, +} from "./lib/file-scanner.ts"; +import { loadConfig } from "./lib/transform.ts"; +import { pushFiles } from "./lib/framer-push.ts"; + +const LAST_PUSH_FILE = path.join(import.meta.dirname, "last-push.txt"); + +// Helper function to create custom hex color +function hexColor(hex: string): (text: string) => string { + // Remove # if present + const cleanHex = hex.replace("#", ""); + // Convert hex to RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + // Return function that applies ANSI color code + return (text: string) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; +} + +// Parse CLI args +const args = process.argv.slice(2); +const forceAll = args.includes("--force"); +const skipConfirm = args.includes("--yes"); + +async function main() { + const projectUrl = process.env["FRAMER_PROJECT_URL"]; + if (!projectUrl) { + console.error( + pc.red("Error: FRAMER_PROJECT_URL environment variable is required") + ); + console.error( + pc.gray( + "Create a .env file with: FRAMER_PROJECT_URL=https://framer.com/projects/..." + ) + ); + process.exit(1); + } + + // Load config + const config = loadConfig(); + console.log( + pc.cyan( + `Loaded config with ${pc.bold( + config.importReplacements.length + )} import rules` + ) + ); + + // Scan files + const allFiles = scanTsxFiles(config.ignoredFiles); + console.log(pc.cyan(`Found ${pc.bold(allFiles.length)} .tsx files total`)); + + // Filter changed files + let filesToPush: ScannedFile[]; + if (forceAll) { + console.log(pc.yellow("Force mode: pushing all files")); + filesToPush = allFiles; + } else { + const lastPushTime = readLastPushTime(LAST_PUSH_FILE); + if (lastPushTime) { + console.log( + pc.gray(`Last push: ${new Date(lastPushTime).toLocaleString()}`) + ); + } else { + console.log(pc.yellow("No previous push recorded, will push all files")); + } + filesToPush = filterChangedFiles(allFiles, lastPushTime); + } + + if (filesToPush.length === 0) { + console.log(pc.green("\n✓ No files to push. All files are up to date.")); + process.exit(0); + } + + // Check which files exist in Framer to determine create vs update + console.log(pc.cyan("Checking existing files in Framer...")); + const { connect } = await import("framer-api"); + const framer = await connect(projectUrl); + let existingFilePaths: Set; + try { + const existingFiles = await framer.getCodeFiles(); + existingFilePaths = new Set(existingFiles.map((f) => f.path)); + } finally { + await framer.disconnect(); + } + + // Categorize files + const filesToCreate = filesToPush.filter( + (f) => !existingFilePaths.has(f.framerPath) + ); + const filesToUpdate = filesToPush.filter((f) => + existingFilePaths.has(f.framerPath) + ); + + // Show files to push + const accentColor = hexColor("FFFFFF"); + console.log(`\n${accentColor("=".repeat(50))}`); + console.log(`Files to push (${filesToPush.length}):`); + console.log("=".repeat(50)); + + // Show files to create (green) + for (const file of filesToCreate) { + const date = new Date(file.mtime).toLocaleString(); + console.log( + ` ${pc.bold(pc.green(file.framerPath))} ${pc.gray( + `(modified: ${date})` + )} ${pc.gray("(new)")}` + ); + } + + // Show files to update (yellow) + for (const file of filesToUpdate) { + const date = new Date(file.mtime).toLocaleString(); + console.log( + ` ${pc.bold(pc.yellow(file.framerPath))} ${pc.gray( + `(modified: ${date})` + )}` + ); + } + console.log(accentColor("=".repeat(50))); + + // Confirm + if (!skipConfirm) { + const confirmed = await askConfirmation( + pc.yellow("\nProceed with push? (Y/Enter to confirm): ") + ); + if (!confirmed) { + console.log(pc.red("Aborted.")); + process.exit(0); + } + } + + // Push files + console.log(pc.cyan("\nPushing files...\n")); + const result = await pushFiles( + projectUrl, + filesToPush, + config.importReplacements, + (msg) => console.log(msg) + ); + + // Save last push time + const now = Date.now(); + saveLastPushTime(LAST_PUSH_FILE, now); + + // Summary + console.log(`\n${pc.bold(pc.green("=".repeat(50)))}`); + console.log(pc.bold(pc.green("Push complete!"))); + console.log( + ` ${pc.green("Created:")} ${pc.bold( + pc.green(result.created.length.toString()) + )}` + ); + console.log( + ` ${pc.blue("Updated:")} ${pc.bold( + pc.blue(result.updated.length.toString()) + )}` + ); + if (result.errors.length > 0) { + console.log( + ` ${pc.red("Errors:")} ${pc.bold( + pc.red(result.errors.length.toString()) + )}` + ); + for (const err of result.errors) { + console.log( + ` ${pc.red("-")} ${pc.red(err.path)}: ${pc.red(err.error)}` + ); + } + } + console.log(pc.green("=".repeat(50))); + process.exit(0); +} + +async function askConfirmation(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === "" || normalized === "y" || normalized === "yes"); + }); + }); +} + +main().catch((err) => { + console.error(pc.red("Fatal error:"), err); + process.exit(1); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..6c2a803 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["*.ts", "lib/**/*.ts"] +} diff --git a/src/pages/upload/lib/config-loader.ts b/src/pages/upload/lib/config-loader.ts index deb6505..2b90292 100644 --- a/src/pages/upload/lib/config-loader.ts +++ b/src/pages/upload/lib/config-loader.ts @@ -4,7 +4,7 @@ import { ImportReplacementRule, StringReplacementRule, } from "./types"; -import { readFileContent, getUploadedRelativePath } from "./file-processing"; +import { readFileContent } from "./file-processing"; const CONFIG_CANDIDATE_NAMES = [ "framer-code-sync.config.json", @@ -27,11 +27,25 @@ export const loadConfigFromUpload = async ( }; const findConfigFile = (files: File[]): File | undefined => { - // Treat uploaded folder's first segment as root, like code files - return files.find((file) => { - const relativeFromRoot = getUploadedRelativePath(file, true); - return CONFIG_CANDIDATE_NAMES.includes(relativeFromRoot); + // Find all files matching config names anywhere in the folder structure + const candidates = files.filter((file) => { + return CONFIG_CANDIDATE_NAMES.includes(file.name); }); + + if (candidates.length === 0) return undefined; + + // Prefer the one closest to the root (shortest path depth) + candidates.sort((a, b) => { + const aPath = + (a as File & { webkitRelativePath?: string }).webkitRelativePath || + a.name; + const bPath = + (b as File & { webkitRelativePath?: string }).webkitRelativePath || + b.name; + return aPath.split("/").length - bPath.split("/").length; + }); + + return candidates[0]; }; const parseConfigJson = (raw: string): unknown => { From 6170aaca9dc8b514af7d63c2c3b053e577f4d59f Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 12:55:00 +0100 Subject: [PATCH 02/14] add cli generalizing --- .claude/CLAUDE.md | 60 +++++++++++++++++++ .gitignore | 4 +- {cli => cli-example-folder-push}/.env.example | 1 - .../Comp.tsx | 0 .../Comp2.tsx | 0 cli/.gitignore | 2 + cli/lib/file-scanner.ts | 9 ++- cli/lib/framer-push.ts | 4 +- cli/lib/transform.ts | 26 +++++--- cli/package.json | 13 +++- cli/push.ts | 47 ++++++++------- cli/tsconfig.json | 4 +- 12 files changed, 127 insertions(+), 43 deletions(-) create mode 100644 .claude/CLAUDE.md rename {cli => cli-example-folder-push}/.env.example (75%) rename {cli-test => cli-example-folder-push}/Comp.tsx (100%) rename {cli-test => cli-example-folder-push}/Comp2.tsx (100%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..ec2805d --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,60 @@ +# Project Overview + +**Code Sync** = Framer plugin + CLI to upload/export `.tsx` between local FS and Framer. + +- **Plugin** (`src/`): React app inside Framer +- **CLI** (`cli/`): Push files via `framer-api` + +## Commands + +Plugin: +- `pnpm dev` dev server (https, mkcert) +- `pnpm build` prod build +- `pnpm lint` + +CLI (from `cli/`): +- `pnpm push` push changed +- `pnpm push:force` push all +- `pnpm push:yes` no confirm + +## Architecture + +### Plugin (`src/`) +- `App.tsx` tabs: Upload / Export / Docs +- `pages/upload` drag-drop upload + transforms +- `pages/export` export Framer code zip +- `pages/docs` docs + +Upload flow (`pages/upload/lib`): +1. load config (`config-loader`) +2. read files + paths (`file-processing`) +3. string/import transforms (`string-transforms`) +4. upload: placeholder → real content (`upload-logic`) + +Types (`types.ts`): +- `CodeSyncConfig` +- `ImportReplacementRule`, `StringReplacementRule` +- `UploadState` + +### CLI (`cli/`) +- `push.ts` args `--force`, `--yes` +- `file-scanner` tsx scan + mtime filter +- `transform` load config + apply rules +- `framer-push` upload via API + +Needs `.env` with `FRAMER_PROJECT_URL`. + +## Config + +`framer-code-sync.config.json` at upload root: +- `version` +- `importReplacements` +- `stringReplacements` +- `ignoredFiles` + +## Code Style + +- Tailwind only, no CSS files +- Handlers: `handleClick`, `handleKeyDown` +- Early returns +- Use `framer-plugin` SDK \ No newline at end of file diff --git a/.gitignore b/.gitignore index 65d8f01..8641f35 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ yarn-error.log\* plugin.zip .cursor -/comps \ No newline at end of file +/comps +.env +.framer-push-time \ No newline at end of file diff --git a/cli/.env.example b/cli-example-folder-push/.env.example similarity index 75% rename from cli/.env.example rename to cli-example-folder-push/.env.example index 5c2237f..adacf98 100644 --- a/cli/.env.example +++ b/cli-example-folder-push/.env.example @@ -1,4 +1,3 @@ -# Framer project URL for staging components # Get this from your Framer project settings FRAMER_PROJECT_URL=https://framer.com/projects/Your-Project--xxxxxxxxxxxxxx FRAMER_API_KEY=xxx \ No newline at end of file diff --git a/cli-test/Comp.tsx b/cli-example-folder-push/Comp.tsx similarity index 100% rename from cli-test/Comp.tsx rename to cli-example-folder-push/Comp.tsx diff --git a/cli-test/Comp2.tsx b/cli-example-folder-push/Comp2.tsx similarity index 100% rename from cli-test/Comp2.tsx rename to cli-example-folder-push/Comp2.tsx diff --git a/cli/.gitignore b/cli/.gitignore index c7511cf..f0981c9 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,3 +1,5 @@ .env last-push.txt +.framer-push-time node_modules/ +/dist diff --git a/cli/lib/file-scanner.ts b/cli/lib/file-scanner.ts index 478e817..73c0678 100644 --- a/cli/lib/file-scanner.ts +++ b/cli/lib/file-scanner.ts @@ -13,10 +13,9 @@ export interface ScanResult { changedFiles: ScannedFile[]; } -const COMPONENTS_DIR = path.resolve( - import.meta.dirname, - "../../cli-test" -); +export function getComponentsDir(): string { + return process.cwd(); +} export function scanTsxFiles(ignoredFiles: string[] = []): ScannedFile[] { const files: ScannedFile[] = []; @@ -53,7 +52,7 @@ export function scanTsxFiles(ignoredFiles: string[] = []): ScannedFile[] { } } - scanDir(COMPONENTS_DIR); + scanDir(getComponentsDir()); return files; } diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts index 2a4c9e8..f39ab42 100644 --- a/cli/lib/framer-push.ts +++ b/cli/lib/framer-push.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; -import type { ScannedFile } from "./file-scanner.ts"; -import { transformContent, type ImportReplacementRule } from "./transform.ts"; +import type { ScannedFile } from "./file-scanner.js"; +import { transformContent, type ImportReplacementRule } from "./transform.js"; const DUMMY_CONTENT = `export default function Test() { return
Test
}`; const CONCURRENCY = 10; // Max parallel requests diff --git a/cli/lib/transform.ts b/cli/lib/transform.ts index a7a1a9e..0e1c14e 100644 --- a/cli/lib/transform.ts +++ b/cli/lib/transform.ts @@ -12,17 +12,27 @@ export interface CodeSyncConfig { ignoredFiles: string[]; } -const CONFIG_PATH = path.resolve( - import.meta.dirname, - "../../../framer-components/src/frameship-components/framer-code-sync.config.json" -); +const CONFIG_FILENAME = "framer-code-sync.config.json"; -export function loadConfig(): CodeSyncConfig { +export interface LoadConfigResult { + config: CodeSyncConfig; + found: boolean; +} + +export function getConfigPath(): string { + return path.join(process.cwd(), CONFIG_FILENAME); +} + +export function loadConfig(): LoadConfigResult { + const configPath = getConfigPath(); try { - const content = fs.readFileSync(CONFIG_PATH, "utf-8"); - return JSON.parse(content); + const content = fs.readFileSync(configPath, "utf-8"); + return { config: JSON.parse(content), found: true }; } catch { - return { version: 1, importReplacements: [], ignoredFiles: [] }; + return { + config: { version: 1, importReplacements: [], ignoredFiles: [] }, + found: false, + }; } } diff --git a/cli/package.json b/cli/package.json index f0051c6..e7c94e0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,19 +1,26 @@ { - "name": "framer-components-staging-push", - "version": "1.0.0", + "name": "framer-code-sync-cli", + "version": "0.0.1", "type": "module", + "bin": { + "framer-code-sync-cli": "./dist/push.js" + }, + "files": [ + "dist" + ], "scripts": { + "build": "tsc", "push": "tsx push.ts", "push:force": "tsx push.ts --force", "push:yes": "tsx push.ts --yes" }, "dependencies": { + "dotenv": "^16.4.0", "framer-api": "0.0.1-beta.4", "picocolors": "^1.1.1" }, "devDependencies": { "@types/node": "^22.0.0", - "dotenv": "^16.4.0", "tsx": "^4.19.0", "typescript": "^5.5.0" } diff --git a/cli/push.ts b/cli/push.ts index 43c1f08..802da46 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -1,4 +1,5 @@ -import "dotenv/config"; +#!/usr/bin/env node +import dotenv from "dotenv"; import path from "node:path"; import readline from "node:readline"; import pc from "picocolors"; @@ -8,11 +9,14 @@ import { readLastPushTime, saveLastPushTime, type ScannedFile, -} from "./lib/file-scanner.ts"; -import { loadConfig } from "./lib/transform.ts"; -import { pushFiles } from "./lib/framer-push.ts"; +} from "./lib/file-scanner.js"; +import { loadConfig } from "./lib/transform.js"; +import { pushFiles } from "./lib/framer-push.js"; -const LAST_PUSH_FILE = path.join(import.meta.dirname, "last-push.txt"); +// Load .env from current working directory +dotenv.config({ path: path.join(process.cwd(), ".env") }); + +const LAST_PUSH_FILE = path.join(process.cwd(), ".framer-push-time"); // Helper function to create custom hex color function hexColor(hex: string): (text: string) => string { @@ -34,26 +38,27 @@ const skipConfirm = args.includes("--yes"); async function main() { const projectUrl = process.env["FRAMER_PROJECT_URL"]; if (!projectUrl) { - console.error( - pc.red("Error: FRAMER_PROJECT_URL environment variable is required") - ); - console.error( - pc.gray( - "Create a .env file with: FRAMER_PROJECT_URL=https://framer.com/projects/..." - ) - ); + console.error(pc.red("Error: FRAMER_PROJECT_URL not found")); + console.error(pc.gray("Create .env in current directory with:")); + console.error(pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/...")); process.exit(1); } // Load config - const config = loadConfig(); - console.log( - pc.cyan( - `Loaded config with ${pc.bold( - config.importReplacements.length - )} import rules` - ) - ); + const { config, found: configFound } = loadConfig(); + if (!configFound) { + console.log( + pc.yellow("No framer-code-sync.config.json found, using defaults") + ); + } else { + console.log( + pc.cyan( + `Loaded config with ${pc.bold( + config.importReplacements.length + )} import rules` + ) + ); + } // Scan files const allFiles = scanTsxFiles(config.ignoredFiles); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 6c2a803..11f072c 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -3,12 +3,12 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noEmit": true, - "allowImportingTsExtensions": true + "declaration": true }, "include": ["*.ts", "lib/**/*.ts"] } From ddb935efe9f1656f23e9dca14da47e0b4a9e4526 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 13:11:27 +0100 Subject: [PATCH 03/14] Enhance CLI functionality with global installation and usage instructions in README; refactor push command handling and structure in CLI code. --- .claude/CLAUDE.md | 24 +++++++++++++-------- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++ cli/index.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++ cli/package.json | 8 +++---- cli/pnpm-lock.yaml | 6 +++--- cli/push.ts | 22 +++---------------- 6 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 cli/index.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ec2805d..e332925 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,10 +12,14 @@ Plugin: - `pnpm build` prod build - `pnpm lint` -CLI (from `cli/`): -- `pnpm push` push changed -- `pnpm push:force` push all -- `pnpm push:yes` no confirm +CLI (global): +- `framer-code-sync-cli push` push changed +- `framer-code-sync-cli push --force` push all +- `framer-code-sync-cli push --yes` skip confirm + +CLI dev (from `cli/`): +- `pnpm build` compile to dist/ +- `pnpm link --global` link globally ## Architecture @@ -37,12 +41,14 @@ Types (`types.ts`): - `UploadState` ### CLI (`cli/`) -- `push.ts` args `--force`, `--yes` -- `file-scanner` tsx scan + mtime filter -- `transform` load config + apply rules -- `framer-push` upload via API +- `index.ts` entry point, command router +- `push.ts` exports `runPush()`, args `--force`, `--yes` +- `lib/file-scanner` tsx scan from cwd + mtime filter +- `lib/transform` load config from cwd + apply rules +- `lib/framer-push` upload via API -Needs `.env` with `FRAMER_PROJECT_URL`. +Globally installable via `npm i -g framer-code-sync-cli`. +Needs `.env` in cwd with `FRAMER_PROJECT_URL`. ## Config diff --git a/README.md b/README.md index cd84148..ff24e66 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,56 @@ Your selected environment is stored per project via `framer.setPluginData`, so c - `Config not applied` — Ensure `framer-code-sync.config.json` is at the root of your uploaded folder - `Import errors` — Verify that replacement URLs and paths are correct +## 💻 CLI + +Push `.tsx` files to Framer from the command line — no plugin UI needed. + +### Installation + +**From source (current):** + +```bash +git clone https://github.com/david-mcbacon/code-sync-framer-plugin.git +cd framer-code-sync/cli +pnpm install && pnpm build + +# Link globally (first time may need: pnpm setup && restart terminal) +pnpm link --global +``` + +**From npm (once published):** + +```bash +npm i -g framer-code-sync-cli +``` + +### Setup + +Create `.env` in your project root: + +```env +FRAMER_PROJECT_URL=https://framer.com/projects/YOUR-PROJECT-ID +FRAMER_API_KEY=YOUR-API-KEY +``` + +Optionally add `framer-code-sync.config.json` for transforms (same format as plugin config). + +### Usage + +```bash +framer-code-sync-cli push # push changed .tsx files +framer-code-sync-cli push --force # push all files +framer-code-sync-cli push --yes # skip confirmation +framer-code-sync-cli --help # show help +``` + +### How it works + +1. Scans all `.tsx` files in current directory (recursive) +2. Filters to only changed files since last push (stored in `.framer-push-time`) +3. Applies transforms from config (if present) +4. Pushes to Framer via `framer-api` + ## 🤝 Contributing Every developer’s needs are different — that’s why this plugin is open source. diff --git a/cli/index.ts b/cli/index.ts new file mode 100644 index 0000000..99c45a9 --- /dev/null +++ b/cli/index.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import dotenv from "dotenv"; +import path from "node:path"; +import pc from "picocolors"; + +// Load .env from current working directory +dotenv.config({ path: path.join(process.cwd(), ".env") }); + +const args = process.argv.slice(2); +const command = args[0]; + +function printHelp() { + console.log(` +${pc.bold("framer-code-sync-cli")} - Push local .tsx files to Framer + +${pc.bold("Usage:")} + framer-code-sync-cli [options] + +${pc.bold("Commands:")} + push Push changed .tsx files to Framer + +${pc.bold("Push Options:")} + --force Push all files, ignore last push time + --yes Skip confirmation prompt + +${pc.bold("Setup:")} + Create .env in your project root with: + FRAMER_PROJECT_URL=https://framer.com/projects/... + + Optionally add framer-code-sync.config.json for transforms. +`); +} + +async function main() { + if (!command || command === "-h" || command === "--help") { + printHelp(); + process.exit(0); + } + + if (command === "push" || command === "-p" || command === "--push") { + const { runPush } = await import("./push.js"); + const pushArgs = command === "push" ? args.slice(1) : args.slice(1); + await runPush(pushArgs); + } else { + console.error(pc.red(`Unknown command: ${command}`)); + printHelp(); + process.exit(1); + } +} + +main().catch((err) => { + console.error(pc.red("Fatal error:"), err); + process.exit(1); +}); diff --git a/cli/package.json b/cli/package.json index e7c94e0..3d206d3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -3,16 +3,16 @@ "version": "0.0.1", "type": "module", "bin": { - "framer-code-sync-cli": "./dist/push.js" + "framer-code-sync-cli": "./dist/index.js" }, "files": [ "dist" ], "scripts": { "build": "tsc", - "push": "tsx push.ts", - "push:force": "tsx push.ts --force", - "push:yes": "tsx push.ts --yes" + "push": "tsx index.ts push", + "push:force": "tsx index.ts push --force", + "push:yes": "tsx index.ts push --yes" }, "dependencies": { "dotenv": "^16.4.0", diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 559285a..01ff9c6 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + dotenv: + specifier: ^16.4.0 + version: 16.6.1 framer-api: specifier: 0.0.1-beta.4 version: 0.0.1-beta.4 @@ -18,9 +21,6 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.7 - dotenv: - specifier: ^16.4.0 - version: 16.6.1 tsx: specifier: ^4.19.0 version: 4.21.0 diff --git a/cli/push.ts b/cli/push.ts index 802da46..e5c3c99 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node -import dotenv from "dotenv"; import path from "node:path"; import readline from "node:readline"; import pc from "picocolors"; @@ -13,29 +11,20 @@ import { import { loadConfig } from "./lib/transform.js"; import { pushFiles } from "./lib/framer-push.js"; -// Load .env from current working directory -dotenv.config({ path: path.join(process.cwd(), ".env") }); - const LAST_PUSH_FILE = path.join(process.cwd(), ".framer-push-time"); -// Helper function to create custom hex color function hexColor(hex: string): (text: string) => string { - // Remove # if present const cleanHex = hex.replace("#", ""); - // Convert hex to RGB const r = parseInt(cleanHex.substring(0, 2), 16); const g = parseInt(cleanHex.substring(2, 4), 16); const b = parseInt(cleanHex.substring(4, 6), 16); - // Return function that applies ANSI color code return (text: string) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; } -// Parse CLI args -const args = process.argv.slice(2); -const forceAll = args.includes("--force"); -const skipConfirm = args.includes("--yes"); +export async function runPush(args: string[]) { + const forceAll = args.includes("--force"); + const skipConfirm = args.includes("--yes"); -async function main() { const projectUrl = process.env["FRAMER_PROJECT_URL"]; if (!projectUrl) { console.error(pc.red("Error: FRAMER_PROJECT_URL not found")); @@ -200,8 +189,3 @@ async function askConfirmation(prompt: string): Promise { }); }); } - -main().catch((err) => { - console.error(pc.red("Fatal error:"), err); - process.exit(1); -}); From 80934f15ce4d2be4734115e38cb9126509ac17d4 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 13:15:08 +0100 Subject: [PATCH 04/14] Update README with AI agentic coding support details; add Cursor IDE rules and project overview documentation; remove .cursor from .gitignore. --- .cursor/rules/framer-plugin.mdc | 38 ++++++++++++++++ .cursor/rules/project-overview.mdc | 69 ++++++++++++++++++++++++++++++ .gitignore | 1 - README.md | 9 ++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/framer-plugin.mdc create mode 100644 .cursor/rules/project-overview.mdc diff --git a/.cursor/rules/framer-plugin.mdc b/.cursor/rules/framer-plugin.mdc new file mode 100644 index 0000000..f10d348 --- /dev/null +++ b/.cursor/rules/framer-plugin.mdc @@ -0,0 +1,38 @@ +--- +globs: src/* +alwaysApply: false +--- +You are a Senior Front-End Developer and an Expert in ReactJS, TypeScript, HTML, CSS and TailwindCSS. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Confirm, then write code! +- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. + +### Coding Environment + +The user asks questions about the following coding languages: + +- ReactJS +- TypeScript +- TailwindCSS +- HTML +- CSS +- Framer Plugin + +### Code Implementation Guidelines + +Follow these rules when you write code: + +- Use early returns whenever possible to make the code more readable. +- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags. +- Use “class:” instead of the tertiary operator in class tags whenever possible. +- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown. diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..b5015b3 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,69 @@ +--- +alwaysApply: true +--- +# Project Overview + +**Code Sync** = Framer plugin + CLI to upload/export `.tsx` between local FS and Framer. + +- **Plugin** (`src/`): React app inside Framer +- **CLI** (`cli/`): Push files via `framer-api` + +## Commands + +Plugin: +- `pnpm dev` dev server (https, mkcert) +- `pnpm build` prod build +- `pnpm lint` + +CLI (global): +- `framer-code-sync-cli push` push changed +- `framer-code-sync-cli push --force` push all +- `framer-code-sync-cli push --yes` skip confirm + +CLI dev (from `cli/`): +- `pnpm build` compile to dist/ +- `pnpm link --global` link globally + +## Architecture + +### Plugin (`src/`) +- `App.tsx` tabs: Upload / Export / Docs +- `pages/upload` drag-drop upload + transforms +- `pages/export` export Framer code zip +- `pages/docs` docs + +Upload flow (`pages/upload/lib`): +1. load config (`config-loader`) +2. read files + paths (`file-processing`) +3. string/import transforms (`string-transforms`) +4. upload: placeholder → real content (`upload-logic`) + +Types (`types.ts`): +- `CodeSyncConfig` +- `ImportReplacementRule`, `StringReplacementRule` +- `UploadState` + +### CLI (`cli/`) +- `index.ts` entry point, command router +- `push.ts` exports `runPush()`, args `--force`, `--yes` +- `lib/file-scanner` tsx scan from cwd + mtime filter +- `lib/transform` load config from cwd + apply rules +- `lib/framer-push` upload via API + +Globally installable via `npm i -g framer-code-sync-cli`. +Needs `.env` in cwd with `FRAMER_PROJECT_URL`. + +## Config + +`framer-code-sync.config.json` at upload root: +- `version` +- `importReplacements` +- `stringReplacements` +- `ignoredFiles` + +## Code Style + +- Tailwind only, no CSS files +- Handlers: `handleClick`, `handleKeyDown` +- Early returns +- Use `framer-plugin` SDK \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8641f35..162417a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,6 @@ yarn-error.log\* plugin.zip -.cursor /comps .env .framer-push-time \ No newline at end of file diff --git a/README.md b/README.md index ff24e66..6876d95 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ This Framer plugin is built as a modern React application with the following tec - **CSS Styling** - Combination of inline styles and external `App.css` file. Planning to migrate to Tailwind CSS in the future. - **ESLint** - Code linting and quality assurance +## 🤖 AI Agentic Coding Support + +This repository includes `.cursor` and `.claude` folders with rules and context specifically designed to enhance AI agentic coding experiences. These folders contain: + +- **`.cursor/rules/`** - Cursor IDE rules for improved AI-assisted development +- **`.claude/`** - Claude Code AI context and instructions for better code understanding + +These files provide AI agents with project-specific knowledge, coding standards, and development workflows to ensure consistent and high-quality contributions. + ## ⚡ Quick Start 1. **Select your upload mode** — folder or individual files From 0b2bde708ea1d23c042937c91d49d29e61abda26 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 17:21:13 +0100 Subject: [PATCH 05/14] Enhance CLI push command with environment selection for ENV.tsx replacement; update README with new usage instructions and options. --- README.md | 15 ++++++++----- cli/index.ts | 2 ++ cli/lib/framer-push.ts | 7 +++--- cli/lib/transform.ts | 49 +++++++++++++++++++++++++++++++++++++++++- cli/push.ts | 24 ++++++++++++++++++++- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6876d95..ef90d5d 100644 --- a/README.md +++ b/README.md @@ -172,10 +172,13 @@ Optionally add `framer-code-sync.config.json` for transforms (same format as plu ### Usage ```bash -framer-code-sync-cli push # push changed .tsx files -framer-code-sync-cli push --force # push all files -framer-code-sync-cli push --yes # skip confirmation -framer-code-sync-cli --help # show help +framer-code-sync-cli push # push changed .tsx files (uses staging env by default) +framer-code-sync-cli push --force # push all files +framer-code-sync-cli push --yes # skip confirmation +framer-code-sync-cli push --env production # use production environment +framer-code-sync-cli push --env staging # use staging environment (default) +framer-code-sync-cli push --env development # use development environment +framer-code-sync-cli --help # show help ``` ### How it works @@ -183,7 +186,9 @@ framer-code-sync-cli --help # show help 1. Scans all `.tsx` files in current directory (recursive) 2. Filters to only changed files since last push (stored in `.framer-push-time`) 3. Applies transforms from config (if present) -4. Pushes to Framer via `framer-api` +4. Replaces `ENV.tsx` variables based on selected environment (defaults to `staging`) + - Replaces `ENV.*.development` → `ENV.*.{selected}` (e.g., `ENV.API_URL.development` → `ENV.API_URL.staging`) +5. Pushes to Framer via `framer-api` ## 🤝 Contributing diff --git a/cli/index.ts b/cli/index.ts index 99c45a9..128f19b 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -22,6 +22,8 @@ ${pc.bold("Commands:")} ${pc.bold("Push Options:")} --force Push all files, ignore last push time --yes Skip confirmation prompt + --env Environment for ENV.tsx replacement (development|staging|production) + Default: staging ${pc.bold("Setup:")} Create .env in your project root with: diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts index f39ab42..dae78ac 100644 --- a/cli/lib/framer-push.ts +++ b/cli/lib/framer-push.ts @@ -49,7 +49,8 @@ export async function pushFiles( projectUrl: string, files: ScannedFile[], importRules: ImportReplacementRule[], - onProgress: (message: string) => void + onProgress: (message: string) => void, + envTarget: string = "staging" ): Promise { const result: PushResult = { created: [], updated: [], errors: [] }; @@ -106,7 +107,7 @@ export async function pushFiles( async ({ file, created }) => { try { const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); - const transformed = transformContent(rawContent, importRules, file.framerPath); + const transformed = transformContent(rawContent, importRules, file.framerPath, envTarget); await created!.setFileContent(transformed); result.created.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); @@ -140,7 +141,7 @@ export async function pushFiles( async ({ file, existing }) => { try { const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); - const transformed = transformContent(rawContent, importRules, file.framerPath); + const transformed = transformContent(rawContent, importRules, file.framerPath, envTarget); await existing.setFileContent(transformed); result.updated.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); diff --git a/cli/lib/transform.ts b/cli/lib/transform.ts index 0e1c14e..fae6064 100644 --- a/cli/lib/transform.ts +++ b/cli/lib/transform.ts @@ -6,6 +6,11 @@ export interface ImportReplacementRule { replace: string; } +export interface EnvReplacementRule { + from: string; + to: string; +} + export interface CodeSyncConfig { version: number; importReplacements: ImportReplacementRule[]; @@ -39,10 +44,18 @@ export function loadConfig(): LoadConfigResult { export function transformContent( content: string, rules: ImportReplacementRule[], - framerPath: string + framerPath: string, + envTarget: string = "staging" ): string { let output = content; output = applyImportReplacements(output, rules, framerPath); + + // Apply ENV replacement + const envReplacementRules: EnvReplacementRule[] = [ + { from: "development", to: envTarget }, + ]; + output = applyEnvReplacement(output, envReplacementRules); + output = ensureTsxExtensions(output); return output; } @@ -146,6 +159,40 @@ function replaceImportSpecifier( return content; } +function applyEnvReplacement( + content: string, + replacementRules: EnvReplacementRule[] +): string { + if (!replacementRules.length) return content; + + for (const rule of replacementRules) { + const { from, to } = rule; + + // Escape special regex characters in environment names + const escapedFrom = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Pattern 1: ENV.something.from -> ENV.something.to + content = content.replace( + new RegExp( + `\\bENV\\.([a-zA-Z_$][a-zA-Z0-9_$]*)\\.${escapedFrom}\\b`, + "g" + ), + `ENV.$1.${to}` + ); + + // Pattern 2: ENV["something"]["from"] or ENV['something']['from'] + content = content.replace( + new RegExp( + `\\bENV\\[(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\\1\\]\\[(['"])${escapedFrom}\\3\\]`, + "g" + ), + `ENV[$1$2$1][$3${to}$3]` + ); + } + + return content; +} + function ensureTsxExtensions(content: string): string { // Match import statements with relative paths (starting with . or ..) const importPattern = /(from\s+|import\s+)(["'])(\.\.[^"']*|\.\/[^"']*)\2/g; diff --git a/cli/push.ts b/cli/push.ts index e5c3c99..2dff2eb 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -25,6 +25,26 @@ export async function runPush(args: string[]) { const forceAll = args.includes("--force"); const skipConfirm = args.includes("--yes"); + // Parse --env or --environment argument + let envTarget = "staging"; // default + const envIndex = args.findIndex( + (arg) => arg === "--env" || arg === "--environment" + ); + if (envIndex !== -1 && envIndex + 1 < args.length) { + const envValue = args[envIndex + 1]; + const validEnvs = ["development", "staging", "production"]; + if (validEnvs.includes(envValue)) { + envTarget = envValue; + } else { + console.error( + pc.red( + `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}` + ) + ); + process.exit(1); + } + } + const projectUrl = process.env["FRAMER_PROJECT_URL"]; if (!projectUrl) { console.error(pc.red("Error: FRAMER_PROJECT_URL not found")); @@ -135,11 +155,13 @@ export async function runPush(args: string[]) { // Push files console.log(pc.cyan("\nPushing files...\n")); + console.log(pc.gray(`Environment: ${pc.bold(envTarget)}`)); const result = await pushFiles( projectUrl, filesToPush, config.importReplacements, - (msg) => console.log(msg) + (msg) => console.log(msg), + envTarget ); // Save last push time From 43e04af5fa9a95f2d8f01890d11774f13c545feb Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 17:28:36 +0100 Subject: [PATCH 06/14] Refactor push command error handling and disconnection logic; ensure Framer connection is established only when needed. --- cli/lib/framer-push.ts | 11 +++++++---- cli/push.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts index dae78ac..26ac747 100644 --- a/cli/lib/framer-push.ts +++ b/cli/lib/framer-push.ts @@ -50,13 +50,16 @@ export async function pushFiles( files: ScannedFile[], importRules: ImportReplacementRule[], onProgress: (message: string) => void, - envTarget: string = "staging" + envTarget: string = "staging", + framer?: Framer ): Promise { const result: PushResult = { created: [], updated: [], errors: [] }; - onProgress("Connecting to Framer..."); - const { connect } = await import("framer-api"); - const framer: Framer = await connect(projectUrl); + if (!framer) { + onProgress("Connecting to Framer..."); + const { connect } = await import("framer-api"); + framer = await connect(projectUrl); + } try { onProgress("Fetching existing code files..."); diff --git a/cli/push.ts b/cli/push.ts index 2dff2eb..29a1213 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -103,8 +103,9 @@ export async function runPush(args: string[]) { try { const existingFiles = await framer.getCodeFiles(); existingFilePaths = new Set(existingFiles.map((f) => f.path)); - } finally { + } catch (err) { await framer.disconnect(); + throw err; } // Categorize files @@ -161,8 +162,16 @@ export async function runPush(args: string[]) { filesToPush, config.importReplacements, (msg) => console.log(msg), - envTarget + envTarget, + framer ); + + // Disconnect after push + try { + await framer.disconnect(); + } catch (err) { + console.error(pc.gray(`Disconnect error (ignored): ${err}`)); + } // Save last push time const now = Date.now(); From bc5a0b9d75cbdb85536b40a067a43a77530442f1 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 17:50:08 +0100 Subject: [PATCH 07/14] Implement caching for Framer file structure in push command; add options for cache refresh and update cache after file push. --- cli/lib/file-scanner.ts | 41 +++++++++++++++++++++++ cli/push.ts | 72 +++++++++++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/cli/lib/file-scanner.ts b/cli/lib/file-scanner.ts index 73c0678..55676aa 100644 --- a/cli/lib/file-scanner.ts +++ b/cli/lib/file-scanner.ts @@ -79,3 +79,44 @@ export function readLastPushTime(filePath: string): number | null { export function saveLastPushTime(filePath: string, timestamp: number): void { fs.writeFileSync(filePath, timestamp.toString(), "utf-8"); } + +interface FramerFilesCache { + files: string[]; + lastUpdated: number; +} + +export function readFramerFilesCache(filePath: string): Set | null { + try { + const content = fs.readFileSync(filePath, "utf-8"); + const cache: FramerFilesCache = JSON.parse(content); + if (cache && Array.isArray(cache.files)) { + return new Set(cache.files); + } + return null; + } catch { + return null; + } +} + +export function saveFramerFilesCache(filePath: string, filePaths: string[]): void { + const cache: FramerFilesCache = { + files: filePaths, + lastUpdated: Date.now(), + }; + fs.writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf-8"); +} + +export function updateFramerFilesCache( + filePath: string, + newFilePaths: string[] +): void { + const existingCache = readFramerFilesCache(filePath); + if (existingCache) { + // Merge new files into existing cache + const merged = new Set([...existingCache, ...newFilePaths]); + saveFramerFilesCache(filePath, Array.from(merged)); + } else { + // Create new cache with just the new files + saveFramerFilesCache(filePath, newFilePaths); + } +} diff --git a/cli/push.ts b/cli/push.ts index 29a1213..ac7dda0 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -6,12 +6,16 @@ import { filterChangedFiles, readLastPushTime, saveLastPushTime, + readFramerFilesCache, + saveFramerFilesCache, + updateFramerFilesCache, type ScannedFile, } from "./lib/file-scanner.js"; import { loadConfig } from "./lib/transform.js"; import { pushFiles } from "./lib/framer-push.js"; const LAST_PUSH_FILE = path.join(process.cwd(), ".framer-push-time"); +const FRAMER_FILES_CACHE = path.join(process.cwd(), ".framer-files.json"); function hexColor(hex: string): (text: string) => string { const cleanHex = hex.replace("#", ""); @@ -24,6 +28,7 @@ function hexColor(hex: string): (text: string) => string { export async function runPush(args: string[]) { const forceAll = args.includes("--force"); const skipConfirm = args.includes("--yes"); + const refreshCache = args.includes("--refresh") || args.includes("--refetch"); // Parse --env or --environment argument let envTarget = "staging"; // default @@ -96,16 +101,45 @@ export async function runPush(args: string[]) { } // Check which files exist in Framer to determine create vs update - console.log(pc.cyan("Checking existing files in Framer...")); - const { connect } = await import("framer-api"); - const framer = await connect(projectUrl); let existingFilePaths: Set; - try { - const existingFiles = await framer.getCodeFiles(); - existingFilePaths = new Set(existingFiles.map((f) => f.path)); - } catch (err) { - await framer.disconnect(); - throw err; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let framer: any = null; + + // Try to load from cache first (unless refresh is requested) + if (!refreshCache) { + const cachedFiles = readFramerFilesCache(FRAMER_FILES_CACHE); + if (cachedFiles) { + console.log(pc.cyan("Using cached Framer file structure...")); + existingFilePaths = cachedFiles; + } else { + // Cache doesn't exist, fetch from Framer + console.log(pc.cyan("Cache not found. Fetching files from Framer...")); + const { connect } = await import("framer-api"); + framer = await connect(projectUrl); + try { + const existingFiles = await framer.getCodeFiles(); + existingFilePaths = new Set(existingFiles.map((f: { path: string }) => f.path)); + saveFramerFilesCache(FRAMER_FILES_CACHE, Array.from(existingFilePaths)); + console.log(pc.green(`Cached ${existingFilePaths.size} files`)); + } catch (err) { + await framer.disconnect(); + throw err; + } + } + } else { + // Refresh flag set, fetch from Framer + console.log(pc.cyan("Refreshing cache from Framer...")); + const { connect } = await import("framer-api"); + framer = await connect(projectUrl); + try { + const existingFiles = await framer.getCodeFiles(); + existingFilePaths = new Set(existingFiles.map((f: { path: string }) => f.path)); + saveFramerFilesCache(FRAMER_FILES_CACHE, Array.from(existingFilePaths)); + console.log(pc.green(`Cached ${existingFilePaths.size} files`)); + } catch (err) { + await framer.disconnect(); + throw err; + } } // Categorize files @@ -150,10 +184,25 @@ export async function runPush(args: string[]) { ); if (!confirmed) { console.log(pc.red("Aborted.")); + // Disconnect if we have a connection + if (framer) { + try { + await framer.disconnect(); + } catch { + // Ignore disconnect errors + } + } process.exit(0); } } + // If we don't have a connection yet (used cache), connect now for push + if (!framer) { + console.log(pc.cyan("Connecting to Framer for push...")); + const { connect } = await import("framer-api"); + framer = await connect(projectUrl); + } + // Push files console.log(pc.cyan("\nPushing files...\n")); console.log(pc.gray(`Environment: ${pc.bold(envTarget)}`)); @@ -173,6 +222,11 @@ export async function runPush(args: string[]) { console.error(pc.gray(`Disconnect error (ignored): ${err}`)); } + // Update cache with newly created files + if (result.created.length > 0) { + updateFramerFilesCache(FRAMER_FILES_CACHE, result.created); + } + // Save last push time const now = Date.now(); saveLastPushTime(LAST_PUSH_FILE, now); From c08990cf8738fa24262e6a00956cd9d9ae1bbbe3 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Sat, 17 Jan 2026 18:10:54 +0100 Subject: [PATCH 08/14] add new func docs --- .claude/CLAUDE.md | 7 ++++--- .cursor/rules/project-overview.mdc | 6 ++++-- README.md | 20 +++++++++++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e332925..1f2369d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,6 +16,7 @@ CLI (global): - `framer-code-sync-cli push` push changed - `framer-code-sync-cli push --force` push all - `framer-code-sync-cli push --yes` skip confirm +- `framer-code-sync-cli push --refresh` refresh Framer file cache CLI dev (from `cli/`): - `pnpm build` compile to dist/ @@ -42,11 +43,11 @@ Types (`types.ts`): ### CLI (`cli/`) - `index.ts` entry point, command router -- `push.ts` exports `runPush()`, args `--force`, `--yes` -- `lib/file-scanner` tsx scan from cwd + mtime filter +- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh` +- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache (`.framer-files.json`) - `lib/transform` load config from cwd + apply rules - `lib/framer-push` upload via API - +- Caches Framer file structure to avoid API calls (uses cache by default, `--refresh` forces refetch) Globally installable via `npm i -g framer-code-sync-cli`. Needs `.env` in cwd with `FRAMER_PROJECT_URL`. diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc index b5015b3..92549cf 100644 --- a/.cursor/rules/project-overview.mdc +++ b/.cursor/rules/project-overview.mdc @@ -19,6 +19,7 @@ CLI (global): - `framer-code-sync-cli push` push changed - `framer-code-sync-cli push --force` push all - `framer-code-sync-cli push --yes` skip confirm +- `framer-code-sync-cli push --refresh` refresh Framer file cache CLI dev (from `cli/`): - `pnpm build` compile to dist/ @@ -45,10 +46,11 @@ Types (`types.ts`): ### CLI (`cli/`) - `index.ts` entry point, command router -- `push.ts` exports `runPush()`, args `--force`, `--yes` -- `lib/file-scanner` tsx scan from cwd + mtime filter +- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh` +- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache (`.framer-files.json`) - `lib/transform` load config from cwd + apply rules - `lib/framer-push` upload via API +- Caches Framer file structure to avoid API calls (uses cache by default, `--refresh` forces refetch) Globally installable via `npm i -g framer-code-sync-cli`. Needs `.env` in cwd with `FRAMER_PROJECT_URL`. diff --git a/README.md b/README.md index ef90d5d..6ff8e9c 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ Optionally add `framer-code-sync.config.json` for transforms (same format as plu framer-code-sync-cli push # push changed .tsx files (uses staging env by default) framer-code-sync-cli push --force # push all files framer-code-sync-cli push --yes # skip confirmation +framer-code-sync-cli push --refresh # force refresh of Framer file cache framer-code-sync-cli push --env production # use production environment framer-code-sync-cli push --env staging # use staging environment (default) framer-code-sync-cli push --env development # use development environment @@ -185,10 +186,23 @@ framer-code-sync-cli --help # show help 1. Scans all `.tsx` files in current directory (recursive) 2. Filters to only changed files since last push (stored in `.framer-push-time`) -3. Applies transforms from config (if present) -4. Replaces `ENV.tsx` variables based on selected environment (defaults to `staging`) +3. Checks which files exist in Framer: + - Uses cached file structure from `.framer-files.json` (default, faster) + - Fetches from Framer API if cache missing or `--refresh` flag used +4. Applies transforms from config (if present) +5. Replaces `ENV.tsx` variables based on selected environment (defaults to `staging`) - Replaces `ENV.*.development` → `ENV.*.{selected}` (e.g., `ENV.API_URL.development` → `ENV.API_URL.staging`) -5. Pushes to Framer via `framer-api` +6. Pushes to Framer via `framer-api` +7. Updates cache with newly created files + +### File Caching + +The CLI caches Framer's file structure in `.framer-files.json` to avoid API calls on every push. This significantly speeds up subsequent pushes since it doesn't need to fetch the file list from Framer. + +- **First run**: Fetches from Framer and creates cache +- **Subsequent runs**: Uses cache by default (much faster) +- **Refresh cache**: Use `--refresh` or `--refetch` to force refetch from Framer +- **Auto-update**: Cache is automatically updated with newly created files after each push ## 🤝 Contributing From 1295b5c812a9a441837058af3ad0beb1eca2bb04 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Mon, 19 Jan 2026 17:11:17 +0100 Subject: [PATCH 09/14] Update .gitignore to include .framer-files.json --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 162417a..ff60746 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ plugin.zip /comps .env -.framer-push-time \ No newline at end of file +.framer-push-time +.framer-files.json \ No newline at end of file From a6f1efae44ad9c97aa07c023f5db1961e87a531e Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Mon, 19 Jan 2026 17:31:13 +0100 Subject: [PATCH 10/14] Refactor CLI to support environment-specific configuration --- .claude/CLAUDE.md | 10 +++--- .cursor/rules/project-overview.mdc | 13 +++++--- README.md | 50 ++++++++++++++++++++---------- cli/index.ts | 13 +++++--- cli/push.ts | 44 +++++++++++++++++++++++--- 5 files changed, 95 insertions(+), 35 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1f2369d..16441af 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,13 +43,15 @@ Types (`types.ts`): ### CLI (`cli/`) - `index.ts` entry point, command router -- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh` -- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache (`.framer-files.json`) +- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh`, `--env` +- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache - `lib/transform` load config from cwd + apply rules - `lib/framer-push` upload via API -- Caches Framer file structure to avoid API calls (uses cache by default, `--refresh` forces refetch) +- Environment-specific `.env` files: `.env` (dev), `.env.staging`, `.env.production` +- Environment-specific cache in `.framer-code-sync-cli/`: `.framer-files.json` (dev), `.framer-files.json.{env}` (others) +- Default environment: `development` +- Caches Framer file structure per environment to avoid API calls (uses cache by default, `--refresh` forces refetch) Globally installable via `npm i -g framer-code-sync-cli`. -Needs `.env` in cwd with `FRAMER_PROJECT_URL`. ## Config diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc index 92549cf..efdb3e6 100644 --- a/.cursor/rules/project-overview.mdc +++ b/.cursor/rules/project-overview.mdc @@ -16,10 +16,11 @@ Plugin: - `pnpm lint` CLI (global): -- `framer-code-sync-cli push` push changed +- `framer-code-sync-cli push` push changed (default: development env) - `framer-code-sync-cli push --force` push all - `framer-code-sync-cli push --yes` skip confirm - `framer-code-sync-cli push --refresh` refresh Framer file cache +- `framer-code-sync-cli push --env {development|staging|production}` set environment CLI dev (from `cli/`): - `pnpm build` compile to dist/ @@ -46,14 +47,16 @@ Types (`types.ts`): ### CLI (`cli/`) - `index.ts` entry point, command router -- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh` -- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache (`.framer-files.json`) +- `push.ts` exports `runPush()`, args `--force`, `--yes`, `--refresh`, `--env` +- `lib/file-scanner` tsx scan from cwd + mtime filter + Framer file cache - `lib/transform` load config from cwd + apply rules - `lib/framer-push` upload via API -- Caches Framer file structure to avoid API calls (uses cache by default, `--refresh` forces refetch) +- Environment-specific `.env` files: `.env` (dev), `.env.staging`, `.env.production` +- Environment-specific cache in `.framer-code-sync-cli/`: `.framer-files.json` (dev), `.framer-files.json.{env}` (others) +- Default environment: `development` +- Caches Framer file structure per environment to avoid API calls (uses cache by default, `--refresh` forces refetch) Globally installable via `npm i -g framer-code-sync-cli`. -Needs `.env` in cwd with `FRAMER_PROJECT_URL`. ## Config diff --git a/README.md b/README.md index 6ff8e9c..10b54b1 100644 --- a/README.md +++ b/README.md @@ -160,49 +160,67 @@ npm i -g framer-code-sync-cli ### Setup -Create `.env` in your project root: +Create environment-specific `.env` files in your project root: ```env +# .env (for development - default) FRAMER_PROJECT_URL=https://framer.com/projects/YOUR-PROJECT-ID -FRAMER_API_KEY=YOUR-API-KEY +FRAMER_API_KEY=YOUR-API-KEY=YOUR-API-SECRET + +# .env.staging (for staging) +FRAMER_PROJECT_URL=https://framer.com/projects/YOUR-STAGING-PROJECT-ID +FRAMER_API_KEY=YOUR-API-KEY=YOUR-API-SECRET + +# .env.production (for production) +FRAMER_PROJECT_URL=https://framer.com/projects/YOUR-PROD-PROJECT-ID +FRAMER_API_KEY=YOUR-API-KEY=YOUR-API-SECRET ``` +The CLI will automatically load the correct `.env` file based on the `--env` flag. If the required `.env` file doesn't exist, it will throw an error. + Optionally add `framer-code-sync.config.json` for transforms (same format as plugin config). ### Usage ```bash -framer-code-sync-cli push # push changed .tsx files (uses staging env by default) +framer-code-sync-cli push # push changed .tsx files (uses development env by default) framer-code-sync-cli push --force # push all files framer-code-sync-cli push --yes # skip confirmation framer-code-sync-cli push --refresh # force refresh of Framer file cache -framer-code-sync-cli push --env production # use production environment -framer-code-sync-cli push --env staging # use staging environment (default) -framer-code-sync-cli push --env development # use development environment -framer-code-sync-cli --help # show help +framer-code-sync-cli push --env development # use development environment (default) +framer-code-sync-cli push --env staging # use staging environment +framer-code-sync-cli push --env production # use production environment +framer-code-sync-cli --help # show help ``` ### How it works -1. Scans all `.tsx` files in current directory (recursive) -2. Filters to only changed files since last push (stored in `.framer-push-time`) -3. Checks which files exist in Framer: - - Uses cached file structure from `.framer-files.json` (default, faster) +1. Loads environment-specific `.env` file (`.env`, `.env.staging`, or `.env.production`) based on `--env` flag +2. Scans all `.tsx` files in current directory (recursive) +3. Filters to only changed files since last push (stored in `.framer-code-sync-cli/.framer-push-time` or `.framer-code-sync-cli/.framer-push-time.{env}`) +4. Checks which files exist in Framer: + - Uses cached file structure from `.framer-code-sync-cli/.framer-files.json` (default, faster) + - Environment-specific cache files: `.framer-code-sync-cli/.framer-files.json.{env}` for staging/production - Fetches from Framer API if cache missing or `--refresh` flag used -4. Applies transforms from config (if present) -5. Replaces `ENV.tsx` variables based on selected environment (defaults to `staging`) +5. Applies transforms from config (if present) +6. Replaces `ENV.tsx` variables based on selected environment (defaults to `development`) - Replaces `ENV.*.development` → `ENV.*.{selected}` (e.g., `ENV.API_URL.development` → `ENV.API_URL.staging`) -6. Pushes to Framer via `framer-api` -7. Updates cache with newly created files +7. Pushes to Framer via `framer-api` +8. Updates cache with newly created files ### File Caching -The CLI caches Framer's file structure in `.framer-files.json` to avoid API calls on every push. This significantly speeds up subsequent pushes since it doesn't need to fetch the file list from Framer. +The CLI caches Framer's file structure and push timestamps in `.framer-code-sync-cli/` folder to avoid API calls on every push. Each environment has separate cache files: + +- **Development** (default): `.framer-code-sync-cli/.framer-files.json` and `.framer-code-sync-cli/.framer-push-time` +- **Staging**: `.framer-code-sync-cli/.framer-files.json.staging` and `.framer-code-sync-cli/.framer-push-time.staging` +- **Production**: `.framer-code-sync-cli/.framer-files.json.production` and `.framer-code-sync-cli/.framer-push-time.production` - **First run**: Fetches from Framer and creates cache - **Subsequent runs**: Uses cache by default (much faster) - **Refresh cache**: Use `--refresh` or `--refetch` to force refetch from Framer - **Auto-update**: Cache is automatically updated with newly created files after each push +- **Environment isolation**: Each environment maintains its own cache, so push times and file lists are tracked separately ## 🤝 Contributing diff --git a/cli/index.ts b/cli/index.ts index 128f19b..3d88927 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,10 +1,8 @@ #!/usr/bin/env node -import dotenv from "dotenv"; import path from "node:path"; import pc from "picocolors"; -// Load .env from current working directory -dotenv.config({ path: path.join(process.cwd(), ".env") }); +// Note: Environment-specific .env files are loaded in push.ts based on --env flag const args = process.argv.slice(2); const command = args[0]; @@ -23,10 +21,15 @@ ${pc.bold("Push Options:")} --force Push all files, ignore last push time --yes Skip confirmation prompt --env Environment for ENV.tsx replacement (development|staging|production) - Default: staging + Default: development ${pc.bold("Setup:")} - Create .env in your project root with: + Create environment-specific .env files in your project root: + .env (for development) + .env.staging (for staging) + .env.production (for production) + + Each file should contain: FRAMER_PROJECT_URL=https://framer.com/projects/... Optionally add framer-code-sync.config.json for transforms. diff --git a/cli/push.ts b/cli/push.ts index ac7dda0..3294e76 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -1,5 +1,7 @@ +import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; +import dotenv from "dotenv"; import pc from "picocolors"; import { scanTsxFiles, @@ -14,8 +16,22 @@ import { import { loadConfig } from "./lib/transform.js"; import { pushFiles } from "./lib/framer-push.js"; -const LAST_PUSH_FILE = path.join(process.cwd(), ".framer-push-time"); -const FRAMER_FILES_CACHE = path.join(process.cwd(), ".framer-files.json"); +const CACHE_DIR = path.join(process.cwd(), ".framer-code-sync-cli"); + +function getCacheFilePath(envTarget: string, baseName: string): string { + // Ensure cache directory exists + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } + + // For default environment (development), use base name without suffix + // For other environments, add suffix + const fileName = envTarget === "development" + ? baseName + : `${baseName}.${envTarget}`; + + return path.join(CACHE_DIR, fileName); +} function hexColor(hex: string): (text: string) => string { const cleanHex = hex.replace("#", ""); @@ -31,7 +47,7 @@ export async function runPush(args: string[]) { const refreshCache = args.includes("--refresh") || args.includes("--refetch"); // Parse --env or --environment argument - let envTarget = "staging"; // default + let envTarget = "development"; // default const envIndex = args.findIndex( (arg) => arg === "--env" || arg === "--environment" ); @@ -50,10 +66,28 @@ export async function runPush(args: string[]) { } } + // Load environment-specific .env file + const envFileName = envTarget === "development" ? ".env" : `.env.${envTarget}`; + const envFilePath = path.join(process.cwd(), envFileName); + + if (!fs.existsSync(envFilePath)) { + console.error(pc.red(`Error: ${envFileName} not found`)); + console.error(pc.gray(`Create ${envFileName} in current directory with:`)); + console.error(pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/...")); + process.exit(1); + } + + // Load the environment-specific .env file + dotenv.config({ path: envFilePath }); + + // Get environment-specific cache file paths + const LAST_PUSH_FILE = getCacheFilePath(envTarget, ".framer-push-time"); + const FRAMER_FILES_CACHE = getCacheFilePath(envTarget, ".framer-files.json"); + const projectUrl = process.env["FRAMER_PROJECT_URL"]; if (!projectUrl) { - console.error(pc.red("Error: FRAMER_PROJECT_URL not found")); - console.error(pc.gray("Create .env in current directory with:")); + console.error(pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`)); + console.error(pc.gray(`Ensure ${envFileName} contains:`)); console.error(pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/...")); process.exit(1); } From 77646d415b7efda3268f243c652a10dbdcb9c18c Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Fri, 6 Feb 2026 07:59:29 +0100 Subject: [PATCH 11/14] lint --- cli/lib/framer-push.ts | 63 +++++++++++++++++++------------ cli/push.ts | 84 +++++++++++++++++++++++------------------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts index 26ac747..bdadc87 100644 --- a/cli/lib/framer-push.ts +++ b/cli/lib/framer-push.ts @@ -26,7 +26,7 @@ interface Framer { async function runWithConcurrency( items: T[], fn: (item: T) => Promise, - limit: number + limit: number, ): Promise { const results: R[] = []; let index = 0; @@ -51,7 +51,7 @@ export async function pushFiles( importRules: ImportReplacementRule[], onProgress: (message: string) => void, envTarget: string = "staging", - framer?: Framer + framer?: Framer, ): Promise { const result: PushResult = { created: [], updated: [], errors: [] }; @@ -89,7 +89,10 @@ export async function pushFiles( newFiles, async (file) => { try { - const created = await framer.createCodeFile(file.framerPath, DUMMY_CONTENT); + const created = await framer.createCodeFile( + file.framerPath, + DUMMY_CONTENT, + ); onProgress(` Created: ${file.framerPath}`); return { file, created, error: null }; } catch (err) { @@ -98,19 +101,26 @@ export async function pushFiles( return { file, created: null, error: msg }; } }, - CONCURRENCY + CONCURRENCY, ); // Phase 2: Update new files with real content (parallel) const successfulCreates = createdFiles.filter((r) => r.created !== null); if (successfulCreates.length > 0) { - onProgress(`Updating ${successfulCreates.length} new files with content...`); + onProgress( + `Updating ${successfulCreates.length} new files with content...`, + ); await runWithConcurrency( successfulCreates, async ({ file, created }) => { try { const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); - const transformed = transformContent(rawContent, importRules, file.framerPath, envTarget); + const transformed = transformContent( + rawContent, + importRules, + file.framerPath, + envTarget, + ); await created!.setFileContent(transformed); result.created.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); @@ -126,7 +136,7 @@ export async function pushFiles( } } }, - CONCURRENCY + CONCURRENCY, ); } @@ -141,26 +151,31 @@ export async function pushFiles( onProgress(`Updating ${updateFiles.length} existing files...`); await runWithConcurrency( updateFiles, - async ({ file, existing }) => { - try { - const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); - const transformed = transformContent(rawContent, importRules, file.framerPath, envTarget); - await existing.setFileContent(transformed); + async ({ file, existing }) => { + try { + const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); + const transformed = transformContent( + rawContent, + importRules, + file.framerPath, + envTarget, + ); + await existing.setFileContent(transformed); + result.updated.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Timeout errors mean the file was updated successfully, just treat as success + if (msg.includes("waitForComponentLoader timeout")) { result.updated.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // Timeout errors mean the file was updated successfully, just treat as success - if (msg.includes("waitForComponentLoader timeout")) { - result.updated.push(file.framerPath); - onProgress(` Updated: ${file.framerPath}`); - } else { - result.errors.push({ path: file.framerPath, error: msg }); - onProgress(` Error: ${file.framerPath}: ${msg}`); - } + } else { + result.errors.push({ path: file.framerPath, error: msg }); + onProgress(` Error: ${file.framerPath}: ${msg}`); } - }, - CONCURRENCY + } + }, + CONCURRENCY, ); } } finally { diff --git a/cli/push.ts b/cli/push.ts index 3294e76..3b503c8 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -23,13 +23,12 @@ function getCacheFilePath(envTarget: string, baseName: string): string { if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }); } - + // For default environment (development), use base name without suffix // For other environments, add suffix - const fileName = envTarget === "development" - ? baseName - : `${baseName}.${envTarget}`; - + const fileName = + envTarget === "development" ? baseName : `${baseName}.${envTarget}`; + return path.join(CACHE_DIR, fileName); } @@ -49,7 +48,7 @@ export async function runPush(args: string[]) { // Parse --env or --environment argument let envTarget = "development"; // default const envIndex = args.findIndex( - (arg) => arg === "--env" || arg === "--environment" + (arg) => arg === "--env" || arg === "--environment", ); if (envIndex !== -1 && envIndex + 1 < args.length) { const envValue = args[envIndex + 1]; @@ -59,24 +58,27 @@ export async function runPush(args: string[]) { } else { console.error( pc.red( - `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}` - ) + `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}`, + ), ); process.exit(1); } } // Load environment-specific .env file - const envFileName = envTarget === "development" ? ".env" : `.env.${envTarget}`; + const envFileName = + envTarget === "development" ? ".env" : `.env.${envTarget}`; const envFilePath = path.join(process.cwd(), envFileName); - + if (!fs.existsSync(envFilePath)) { console.error(pc.red(`Error: ${envFileName} not found`)); console.error(pc.gray(`Create ${envFileName} in current directory with:`)); - console.error(pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/...")); + console.error( + pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/..."), + ); process.exit(1); } - + // Load the environment-specific .env file dotenv.config({ path: envFilePath }); @@ -86,9 +88,13 @@ export async function runPush(args: string[]) { const projectUrl = process.env["FRAMER_PROJECT_URL"]; if (!projectUrl) { - console.error(pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`)); + console.error( + pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`), + ); console.error(pc.gray(`Ensure ${envFileName} contains:`)); - console.error(pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/...")); + console.error( + pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/..."), + ); process.exit(1); } @@ -96,15 +102,15 @@ export async function runPush(args: string[]) { const { config, found: configFound } = loadConfig(); if (!configFound) { console.log( - pc.yellow("No framer-code-sync.config.json found, using defaults") + pc.yellow("No framer-code-sync.config.json found, using defaults"), ); } else { console.log( pc.cyan( `Loaded config with ${pc.bold( - config.importReplacements.length - )} import rules` - ) + config.importReplacements.length, + )} import rules`, + ), ); } @@ -121,7 +127,7 @@ export async function runPush(args: string[]) { const lastPushTime = readLastPushTime(LAST_PUSH_FILE); if (lastPushTime) { console.log( - pc.gray(`Last push: ${new Date(lastPushTime).toLocaleString()}`) + pc.gray(`Last push: ${new Date(lastPushTime).toLocaleString()}`), ); } else { console.log(pc.yellow("No previous push recorded, will push all files")); @@ -152,7 +158,9 @@ export async function runPush(args: string[]) { framer = await connect(projectUrl); try { const existingFiles = await framer.getCodeFiles(); - existingFilePaths = new Set(existingFiles.map((f: { path: string }) => f.path)); + existingFilePaths = new Set( + existingFiles.map((f: { path: string }) => f.path), + ); saveFramerFilesCache(FRAMER_FILES_CACHE, Array.from(existingFilePaths)); console.log(pc.green(`Cached ${existingFilePaths.size} files`)); } catch (err) { @@ -167,7 +175,9 @@ export async function runPush(args: string[]) { framer = await connect(projectUrl); try { const existingFiles = await framer.getCodeFiles(); - existingFilePaths = new Set(existingFiles.map((f: { path: string }) => f.path)); + existingFilePaths = new Set( + existingFiles.map((f: { path: string }) => f.path), + ); saveFramerFilesCache(FRAMER_FILES_CACHE, Array.from(existingFilePaths)); console.log(pc.green(`Cached ${existingFilePaths.size} files`)); } catch (err) { @@ -178,10 +188,10 @@ export async function runPush(args: string[]) { // Categorize files const filesToCreate = filesToPush.filter( - (f) => !existingFilePaths.has(f.framerPath) + (f) => !existingFilePaths.has(f.framerPath), ); const filesToUpdate = filesToPush.filter((f) => - existingFilePaths.has(f.framerPath) + existingFilePaths.has(f.framerPath), ); // Show files to push @@ -195,8 +205,8 @@ export async function runPush(args: string[]) { const date = new Date(file.mtime).toLocaleString(); console.log( ` ${pc.bold(pc.green(file.framerPath))} ${pc.gray( - `(modified: ${date})` - )} ${pc.gray("(new)")}` + `(modified: ${date})`, + )} ${pc.gray("(new)")}`, ); } @@ -205,8 +215,8 @@ export async function runPush(args: string[]) { const date = new Date(file.mtime).toLocaleString(); console.log( ` ${pc.bold(pc.yellow(file.framerPath))} ${pc.gray( - `(modified: ${date})` - )}` + `(modified: ${date})`, + )}`, ); } console.log(accentColor("=".repeat(50))); @@ -214,7 +224,7 @@ export async function runPush(args: string[]) { // Confirm if (!skipConfirm) { const confirmed = await askConfirmation( - pc.yellow("\nProceed with push? (Y/Enter to confirm): ") + pc.yellow("\nProceed with push? (Y/Enter to confirm): "), ); if (!confirmed) { console.log(pc.red("Aborted.")); @@ -246,9 +256,9 @@ export async function runPush(args: string[]) { config.importReplacements, (msg) => console.log(msg), envTarget, - framer + framer, ); - + // Disconnect after push try { await framer.disconnect(); @@ -270,23 +280,23 @@ export async function runPush(args: string[]) { console.log(pc.bold(pc.green("Push complete!"))); console.log( ` ${pc.green("Created:")} ${pc.bold( - pc.green(result.created.length.toString()) - )}` + pc.green(result.created.length.toString()), + )}`, ); console.log( ` ${pc.blue("Updated:")} ${pc.bold( - pc.blue(result.updated.length.toString()) - )}` + pc.blue(result.updated.length.toString()), + )}`, ); if (result.errors.length > 0) { console.log( ` ${pc.red("Errors:")} ${pc.bold( - pc.red(result.errors.length.toString()) - )}` + pc.red(result.errors.length.toString()), + )}`, ); for (const err of result.errors) { console.log( - ` ${pc.red("-")} ${pc.red(err.path)}: ${pc.red(err.error)}` + ` ${pc.red("-")} ${pc.red(err.path)}: ${pc.red(err.error)}`, ); } } From 796c7efacbfa1a74ff8e7f2e99ced65ae08b0703 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Fri, 6 Feb 2026 09:46:28 +0100 Subject: [PATCH 12/14] Update framer-api dependency to version 0.1.1 in package.json and pnpm-lock.yaml; remove deprecated dependencies and add std-env package. --- cli/package.json | 2 +- cli/pnpm-lock.yaml | 47 +++++++++++----------------------------------- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3d206d3..a0af9ce 100644 --- a/cli/package.json +++ b/cli/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "dotenv": "^16.4.0", - "framer-api": "0.0.1-beta.4", + "framer-api": "0.1.1", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 01ff9c6..dc43506 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^16.4.0 version: 16.6.1 framer-api: - specifier: 0.0.1-beta.4 - version: 0.0.1-beta.4 + specifier: 0.1.1 + version: 0.1.1 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -201,8 +201,8 @@ packages: engines: {node: '>=18'} hasBin: true - framer-api@0.0.1-beta.4: - resolution: {integrity: sha512-3CbzdemiLkc4SqzhqQ4HdhbWo9+Onu3NUZn1MGaddr00OuWfETHFfasW+e8StrtUecFtLqRFZ/xZh7khf7rciA==} + framer-api@0.1.1: + resolution: {integrity: sha512-Y2/MQWBbhViuUJeS2zwOoKUDfTNkUL/zPO31kIPjVafU2aTbICRegB+6fgbLSaBF4tTpWSTV5XiYMUa7rjf6vw==} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -212,15 +212,15 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -234,21 +234,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - snapshots: '@esbuild/aix-ppc64@0.27.2': @@ -366,14 +351,10 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 - framer-api@0.0.1-beta.4: + framer-api@0.1.1: dependencies: devalue: 5.6.2 - unenv: 2.0.0-rc.24 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + std-env: 3.10.0 fsevents@2.3.3: optional: true @@ -382,12 +363,12 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - pathe@2.0.3: {} - picocolors@1.1.1: {} resolve-pkg-maps@1.0.0: {} + std-env@3.10.0: {} + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -398,9 +379,3 @@ snapshots: typescript@5.9.3: {} undici-types@6.21.0: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - ws@8.19.0: {} From bdc3c6494d7a4c153f40ee34a39e41297a2122a3 Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Wed, 25 Feb 2026 17:53:38 +0100 Subject: [PATCH 13/14] fix cli updating --- cli/lib/framer-push.ts | 194 ++++++++++++++++++----------------------- cli/push.ts | 4 +- 2 files changed, 89 insertions(+), 109 deletions(-) diff --git a/cli/lib/framer-push.ts b/cli/lib/framer-push.ts index bdadc87..cacdbb9 100644 --- a/cli/lib/framer-push.ts +++ b/cli/lib/framer-push.ts @@ -17,7 +17,7 @@ interface CodeFile { setFileContent(code: string): Promise; } -interface Framer { +export interface Framer { getCodeFiles(): Promise; createCodeFile(name: string, code: string): Promise; disconnect(): Promise; @@ -46,8 +46,8 @@ async function runWithConcurrency( } export async function pushFiles( - projectUrl: string, - files: ScannedFile[], + newFiles: ScannedFile[], + existingFiles: ScannedFile[], importRules: ImportReplacementRule[], onProgress: (message: string) => void, envTarget: string = "staging", @@ -56,102 +56,40 @@ export async function pushFiles( const result: PushResult = { created: [], updated: [], errors: [] }; if (!framer) { - onProgress("Connecting to Framer..."); - const { connect } = await import("framer-api"); - framer = await connect(projectUrl); + throw new Error("Framer connection is required"); } - try { - onProgress("Fetching existing code files..."); - const existingFiles = await framer.getCodeFiles(); - const existingFileMap = new Map(); - for (const file of existingFiles) { - existingFileMap.set(file.path, file); - } - - // Separate new files vs existing files - const newFiles: ScannedFile[] = []; - const updateFiles: Array<{ file: ScannedFile; existing: CodeFile }> = []; - - for (const file of files) { - const existing = existingFileMap.get(file.framerPath); - if (existing) { - updateFiles.push({ file, existing }); - } else { - newFiles.push(file); - } - } - - // Phase 1: Create new files with dummy content (parallel) - if (newFiles.length > 0) { - onProgress(`Creating ${newFiles.length} new files...`); - const createdFiles = await runWithConcurrency( - newFiles, - async (file) => { - try { - const created = await framer.createCodeFile( - file.framerPath, - DUMMY_CONTENT, - ); - onProgress(` Created: ${file.framerPath}`); - return { file, created, error: null }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - onProgress(` Error creating ${file.framerPath}: ${msg}`); - return { file, created: null, error: msg }; - } - }, - CONCURRENCY, + // Phase 1: Create new files with dummy content (parallel) + if (newFiles.length > 0) { + onProgress(`Creating ${newFiles.length} new files...`); + const createdFiles = await runWithConcurrency( + newFiles, + async (file) => { + try { + const created = await framer.createCodeFile( + file.framerPath, + DUMMY_CONTENT, + ); + onProgress(` Created: ${file.framerPath}`); + return { file, created, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + onProgress(` Error creating ${file.framerPath}: ${msg}`); + return { file, created: null, error: msg }; + } + }, + CONCURRENCY, + ); + + // Phase 2: Update new files with real content (parallel) + const successfulCreates = createdFiles.filter((r) => r.created !== null); + if (successfulCreates.length > 0) { + onProgress( + `Updating ${successfulCreates.length} new files with content...`, ); - - // Phase 2: Update new files with real content (parallel) - const successfulCreates = createdFiles.filter((r) => r.created !== null); - if (successfulCreates.length > 0) { - onProgress( - `Updating ${successfulCreates.length} new files with content...`, - ); - await runWithConcurrency( - successfulCreates, - async ({ file, created }) => { - try { - const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); - const transformed = transformContent( - rawContent, - importRules, - file.framerPath, - envTarget, - ); - await created!.setFileContent(transformed); - result.created.push(file.framerPath); - onProgress(` Updated: ${file.framerPath}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // Timeout errors mean the file was created successfully, just treat as success - if (msg.includes("waitForComponentLoader timeout")) { - result.created.push(file.framerPath); - onProgress(` Updated: ${file.framerPath}`); - } else { - result.errors.push({ path: file.framerPath, error: msg }); - onProgress(` Error updating ${file.framerPath}: ${msg}`); - } - } - }, - CONCURRENCY, - ); - } - - // Record creation errors - for (const { file, error } of createdFiles) { - if (error) result.errors.push({ path: file.framerPath, error }); - } - } - - // Phase 3: Update existing files (parallel) - if (updateFiles.length > 0) { - onProgress(`Updating ${updateFiles.length} existing files...`); await runWithConcurrency( - updateFiles, - async ({ file, existing }) => { + successfulCreates, + async ({ file, created }) => { try { const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); const transformed = transformContent( @@ -160,32 +98,74 @@ export async function pushFiles( file.framerPath, envTarget, ); - await existing.setFileContent(transformed); - result.updated.push(file.framerPath); + await created!.setFileContent(transformed); + result.created.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - // Timeout errors mean the file was updated successfully, just treat as success if (msg.includes("waitForComponentLoader timeout")) { - result.updated.push(file.framerPath); + result.created.push(file.framerPath); onProgress(` Updated: ${file.framerPath}`); } else { result.errors.push({ path: file.framerPath, error: msg }); - onProgress(` Error: ${file.framerPath}: ${msg}`); + onProgress(` Error updating ${file.framerPath}: ${msg}`); } } }, CONCURRENCY, ); } - } finally { - onProgress("Disconnecting from Framer..."); - try { - await framer.disconnect(); - onProgress("Disconnected."); - } catch (err) { - onProgress(`Disconnect error (ignored): ${err}`); + + // Record creation errors + for (const { file, error } of createdFiles) { + if (error) result.errors.push({ path: file.framerPath, error }); + } + } + + // Phase 3: Update existing files (parallel) + if (existingFiles.length > 0) { + onProgress("Fetching existing code files..."); + const codeFiles = await framer.getCodeFiles(); + const codeFileMap = new Map(); + for (const cf of codeFiles) { + codeFileMap.set(cf.path, cf); } + + onProgress(`Updating ${existingFiles.length} existing files...`); + await runWithConcurrency( + existingFiles, + async (file) => { + const codeFile = codeFileMap.get(file.framerPath); + if (!codeFile) { + const msg = `File not found in Framer (skipped to avoid duplicate): ${file.framerPath}`; + onProgress(` Warning: ${msg}`); + result.errors.push({ path: file.framerPath, error: msg }); + return; + } + try { + const rawContent = fs.readFileSync(file.absolutePath, "utf-8"); + const transformed = transformContent( + rawContent, + importRules, + file.framerPath, + envTarget, + ); + await codeFile.setFileContent(transformed); + result.updated.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("waitForComponentLoader timeout")) { + result.updated.push(file.framerPath); + onProgress(` Updated: ${file.framerPath}`); + } else { + result.errors.push({ path: file.framerPath, error: msg }); + onProgress(` Error: ${file.framerPath}: ${msg}`); + } + } + }, + CONCURRENCY, + ); } return result; diff --git a/cli/push.ts b/cli/push.ts index 3b503c8..653771e 100644 --- a/cli/push.ts +++ b/cli/push.ts @@ -251,8 +251,8 @@ export async function runPush(args: string[]) { console.log(pc.cyan("\nPushing files...\n")); console.log(pc.gray(`Environment: ${pc.bold(envTarget)}`)); const result = await pushFiles( - projectUrl, - filesToPush, + filesToCreate, + filesToUpdate, config.importReplacements, (msg) => console.log(msg), envTarget, From 1a1ec815885586e72aaf13c213acccebbfd5d3de Mon Sep 17 00:00:00 2001 From: David Slaninka Date: Wed, 25 Feb 2026 18:12:30 +0100 Subject: [PATCH 14/14] add new commands --- README.md | 16 +++++++-- cli/get.ts | 82 +++++++++++++++++++++++++++++++++++++++++++ cli/index.ts | 15 ++++++-- cli/insert-url.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++ cli/list.ts | 78 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 cli/get.ts create mode 100644 cli/insert-url.ts create mode 100644 cli/list.ts diff --git a/README.md b/README.md index 10b54b1..c4a440e 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,12 @@ The plugin merges UI settings with your config, giving **priority to the config "version": 1, "importReplacements": [ { "find": "@stripe/stripe-js", "replace": "./Bundles/Stripe_bundle.tsx" }, - { "find": "./mock/helpers", "replace": "https://example.com/helpers.js" } + { "find": "./mock/helpers", "replace": "https://example.com/helpers.js" }, ], "ignoredFiles": ["./internal/mock.tsx"], "stringReplacements": [ - { "find": "(api.tasks.get)", "replace": "(\"tasks:get\")" } - ] + { "find": "(api.tasks.get)", "replace": "(\"tasks:get\")" }, + ], } ``` @@ -190,6 +190,16 @@ framer-code-sync-cli push --refresh # force refresh of Framer file cach framer-code-sync-cli push --env development # use development environment (default) framer-code-sync-cli push --env staging # use staging environment framer-code-sync-cli push --env production # use production environment + +framer-code-sync-cli list # list all files in the Framer project +framer-code-sync-cli list --env staging # list files for a specific environment + +framer-code-sync-cli get # output the source code of a Framer file to stdout +framer-code-sync-cli get --env staging + +framer-code-sync-cli insert-url # output insertURL(s) for all component exports in a file +framer-code-sync-cli insert-url --env staging + framer-code-sync-cli --help # show help ``` diff --git a/cli/get.ts b/cli/get.ts new file mode 100644 index 0000000..8f605e1 --- /dev/null +++ b/cli/get.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import pc from "picocolors"; + +export async function runGet(args: string[]) { + let envTarget = "development"; + const envIndex = args.findIndex( + (arg) => arg === "--env" || arg === "--environment", + ); + if (envIndex !== -1 && envIndex + 1 < args.length) { + const envValue = args[envIndex + 1]; + const validEnvs = ["development", "staging", "production"]; + if (validEnvs.includes(envValue)) { + envTarget = envValue; + } else { + console.error( + pc.red( + `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}`, + ), + ); + process.exit(1); + } + } + + // First positional arg (non-flag) + const filePath = args.find((a) => !a.startsWith("--")); + if (!filePath) { + console.error(pc.red("Error: file path required")); + console.error(pc.gray("Usage: framer-code-sync-cli get ")); + process.exit(1); + } + + const envFileName = + envTarget === "development" ? ".env" : `.env.${envTarget}`; + const envFilePath = path.join(process.cwd(), envFileName); + + if (!fs.existsSync(envFilePath)) { + console.error(pc.red(`Error: ${envFileName} not found`)); + process.exit(1); + } + + dotenv.config({ path: envFilePath }); + + const projectUrl = process.env["FRAMER_PROJECT_URL"]; + if (!projectUrl) { + console.error( + pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`), + ); + process.exit(1); + } + + const { connect } = await import("framer-api"); + const framer = await connect(projectUrl); + + try { + const files = await framer.getCodeFiles(); + const file = files.find((f) => f.path === filePath); + + if (!file) { + console.error(pc.red(`Error: file not found in Framer: ${filePath}`)); + console.error(pc.gray(`Run "framer-code-sync-cli list" to see all files`)); + process.exit(1); + } + + const versions = await file.getVersions(); + if (versions.length === 0) { + console.error(pc.red("Error: no versions found for this file")); + process.exit(1); + } + + // Latest version is first + const content = await versions[0].getContent(); + process.stdout.write(content); + } finally { + try { + await framer.disconnect(); + } catch { + // ignore + } + } +} diff --git a/cli/index.ts b/cli/index.ts index 3d88927..62ef029 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -16,6 +16,9 @@ ${pc.bold("Usage:")} ${pc.bold("Commands:")} push Push changed .tsx files to Framer + list List all files in Framer project + get Output content of a file in Framer + insert-url Output insertURL(s) for components in a Framer file ${pc.bold("Push Options:")} --force Push all files, ignore last push time @@ -44,8 +47,16 @@ async function main() { if (command === "push" || command === "-p" || command === "--push") { const { runPush } = await import("./push.js"); - const pushArgs = command === "push" ? args.slice(1) : args.slice(1); - await runPush(pushArgs); + await runPush(args.slice(1)); + } else if (command === "list" || command === "-l" || command === "--list") { + const { runList } = await import("./list.js"); + await runList(args.slice(1)); + } else if (command === "get") { + const { runGet } = await import("./get.js"); + await runGet(args.slice(1)); + } else if (command === "insert-url") { + const { runInsertUrl } = await import("./insert-url.js"); + await runInsertUrl(args.slice(1)); } else { console.error(pc.red(`Unknown command: ${command}`)); printHelp(); diff --git a/cli/insert-url.ts b/cli/insert-url.ts new file mode 100644 index 0000000..3318dec --- /dev/null +++ b/cli/insert-url.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import pc from "picocolors"; + +export async function runInsertUrl(args: string[]) { + let envTarget = "development"; + const envIndex = args.findIndex( + (arg) => arg === "--env" || arg === "--environment", + ); + if (envIndex !== -1 && envIndex + 1 < args.length) { + const envValue = args[envIndex + 1]; + const validEnvs = ["development", "staging", "production"]; + if (validEnvs.includes(envValue)) { + envTarget = envValue; + } else { + console.error( + pc.red( + `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}`, + ), + ); + process.exit(1); + } + } + + const filePath = args.find((a) => !a.startsWith("--")); + if (!filePath) { + console.error(pc.red("Error: file path required")); + console.error( + pc.gray("Usage: framer-code-sync-cli insert-url "), + ); + process.exit(1); + } + + const envFileName = + envTarget === "development" ? ".env" : `.env.${envTarget}`; + const envFilePath = path.join(process.cwd(), envFileName); + + if (!fs.existsSync(envFilePath)) { + console.error(pc.red(`Error: ${envFileName} not found`)); + process.exit(1); + } + + dotenv.config({ path: envFilePath }); + + const projectUrl = process.env["FRAMER_PROJECT_URL"]; + if (!projectUrl) { + console.error( + pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`), + ); + process.exit(1); + } + + const { connect, isCodeFileComponentExport } = await import("framer-api"); + const framer = await connect(projectUrl); + + try { + const files = await framer.getCodeFiles(); + const file = files.find((f) => f.path === filePath); + + if (!file) { + console.error(pc.red(`Error: file not found in Framer: ${filePath}`)); + console.error( + pc.gray(`Run "framer-code-sync-cli list" to see all files`), + ); + process.exit(1); + } + + const componentExports = file.exports.filter(isCodeFileComponentExport); + + if (componentExports.length === 0) { + console.error( + pc.red(`No component exports found in: ${filePath}`), + ); + process.exit(1); + } + + for (const exp of componentExports) { + console.log(`${pc.cyan(exp.name)}: ${exp.insertURL}`); + } + } finally { + try { + await framer.disconnect(); + } catch { + // ignore + } + } +} diff --git a/cli/list.ts b/cli/list.ts new file mode 100644 index 0000000..912f83d --- /dev/null +++ b/cli/list.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import pc from "picocolors"; + +export async function runList(args: string[]) { + let envTarget = "development"; + const envIndex = args.findIndex( + (arg) => arg === "--env" || arg === "--environment", + ); + if (envIndex !== -1 && envIndex + 1 < args.length) { + const envValue = args[envIndex + 1]; + const validEnvs = ["development", "staging", "production"]; + if (validEnvs.includes(envValue)) { + envTarget = envValue; + } else { + console.error( + pc.red( + `Error: Invalid environment "${envValue}". Must be one of: ${validEnvs.join(", ")}`, + ), + ); + process.exit(1); + } + } + + const envFileName = + envTarget === "development" ? ".env" : `.env.${envTarget}`; + const envFilePath = path.join(process.cwd(), envFileName); + + if (!fs.existsSync(envFilePath)) { + console.error(pc.red(`Error: ${envFileName} not found`)); + console.error(pc.gray(`Create ${envFileName} in current directory with:`)); + console.error( + pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/..."), + ); + process.exit(1); + } + + dotenv.config({ path: envFilePath }); + + const projectUrl = process.env["FRAMER_PROJECT_URL"]; + if (!projectUrl) { + console.error( + pc.red(`Error: FRAMER_PROJECT_URL not found in ${envFileName}`), + ); + process.exit(1); + } + + console.log(pc.cyan(`Fetching files from Framer (${envTarget})...`)); + + const { connect } = await import("framer-api"); + const framer = await connect(projectUrl); + + let files: readonly { path: string }[]; + try { + files = await framer.getCodeFiles(); + } finally { + try { + await framer.disconnect(); + } catch { + // ignore + } + } + + if (files.length === 0) { + console.log(pc.yellow("No files found in Framer project.")); + return; + } + + const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); + + console.log(`\n${pc.bold(`Files in Framer (${sorted.length}):`)}`); + console.log("=".repeat(50)); + for (const file of sorted) { + console.log(` ${pc.cyan(file.path)}`); + } + console.log("=".repeat(50)); +}