diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..16441af --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,69 @@ +# 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 +- `framer-code-sync-cli push --refresh` refresh Framer file cache + +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`, `--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 +- 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`. + +## 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/.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..efdb3e6 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,74 @@ +--- +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 (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/ +- `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`, `--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 +- 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`. + +## 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..ff60746 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,7 @@ yarn-error.log\* plugin.zip -.cursor -/comps \ No newline at end of file +/comps +.env +.framer-push-time +.framer-files.json \ No newline at end of file diff --git a/README.md b/README.md index cd84148..c4a440e 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 @@ -75,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\")" }, + ], } ``` @@ -126,6 +135,103 @@ 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 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=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 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 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 +``` + +### How it works + +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 +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`) +7. Pushes to Framer via `framer-api` +8. Updates cache with newly created files + +### File Caching + +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 Every developer’s needs are different — that’s why this plugin is open source. diff --git a/cli-example-folder-push/.env.example b/cli-example-folder-push/.env.example new file mode 100644 index 0000000..adacf98 --- /dev/null +++ b/cli-example-folder-push/.env.example @@ -0,0 +1,3 @@ +# 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-example-folder-push/Comp.tsx b/cli-example-folder-push/Comp.tsx new file mode 100644 index 0000000..995576f --- /dev/null +++ b/cli-example-folder-push/Comp.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function Comp() { + return ( +
Comp
+ ) +} diff --git a/cli-example-folder-push/Comp2.tsx b/cli-example-folder-push/Comp2.tsx new file mode 100644 index 0000000..53f499a --- /dev/null +++ b/cli-example-folder-push/Comp2.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function Comp2() { + return ( +
Comp2
+ ) +} diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..f0981c9 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,5 @@ +.env +last-push.txt +.framer-push-time +node_modules/ +/dist 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 new file mode 100644 index 0000000..62ef029 --- /dev/null +++ b/cli/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import path from "node:path"; +import pc from "picocolors"; + +// Note: Environment-specific .env files are loaded in push.ts based on --env flag + +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 + 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 + --yes Skip confirmation prompt + --env Environment for ENV.tsx replacement (development|staging|production) + Default: development + +${pc.bold("Setup:")} + 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. +`); +} + +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"); + 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(); + process.exit(1); + } +} + +main().catch((err) => { + console.error(pc.red("Fatal error:"), err); + process.exit(1); +}); 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/lib/file-scanner.ts b/cli/lib/file-scanner.ts new file mode 100644 index 0000000..55676aa --- /dev/null +++ b/cli/lib/file-scanner.ts @@ -0,0 +1,122 @@ +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[]; +} + +export function getComponentsDir(): string { + return process.cwd(); +} + +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(getComponentsDir()); + 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"); +} + +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/lib/framer-push.ts b/cli/lib/framer-push.ts new file mode 100644 index 0000000..cacdbb9 --- /dev/null +++ b/cli/lib/framer-push.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +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 + +export interface PushResult { + created: string[]; + updated: string[]; + errors: Array<{ path: string; error: string }>; +} + +interface CodeFile { + id: string; + path: string; + setFileContent(code: string): Promise; +} + +export 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( + newFiles: ScannedFile[], + existingFiles: ScannedFile[], + importRules: ImportReplacementRule[], + onProgress: (message: string) => void, + envTarget: string = "staging", + framer?: Framer, +): Promise { + const result: PushResult = { created: [], updated: [], errors: [] }; + + if (!framer) { + throw new Error("Framer connection is required"); + } + + // 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, + 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); + 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 (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/lib/transform.ts b/cli/lib/transform.ts new file mode 100644 index 0000000..fae6064 --- /dev/null +++ b/cli/lib/transform.ts @@ -0,0 +1,207 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface ImportReplacementRule { + find: string; + replace: string; +} + +export interface EnvReplacementRule { + from: string; + to: string; +} + +export interface CodeSyncConfig { + version: number; + importReplacements: ImportReplacementRule[]; + ignoredFiles: string[]; +} + +const CONFIG_FILENAME = "framer-code-sync.config.json"; + +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(configPath, "utf-8"); + return { config: JSON.parse(content), found: true }; + } catch { + return { + config: { version: 1, importReplacements: [], ignoredFiles: [] }, + found: false, + }; + } +} + +export function transformContent( + content: string, + rules: ImportReplacementRule[], + 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; +} + +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 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; + + 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/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)); +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..a0af9ce --- /dev/null +++ b/cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "framer-code-sync-cli", + "version": "0.0.1", + "type": "module", + "bin": { + "framer-code-sync-cli": "./dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "push": "tsx index.ts push", + "push:force": "tsx index.ts push --force", + "push:yes": "tsx index.ts push --yes" + }, + "dependencies": { + "dotenv": "^16.4.0", + "framer-api": "0.1.1", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.0.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..dc43506 --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,381 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + dotenv: + specifier: ^16.4.0 + version: 16.6.1 + framer-api: + specifier: 0.1.1 + version: 0.1.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + 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.1.1: + resolution: {integrity: sha512-Y2/MQWBbhViuUJeS2zwOoKUDfTNkUL/zPO31kIPjVafU2aTbICRegB+6fgbLSaBF4tTpWSTV5XiYMUa7rjf6vw==} + + 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==} + + 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'} + 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==} + +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.1.1: + dependencies: + devalue: 5.6.2 + std-env: 3.10.0 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + picocolors@1.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + std-env@3.10.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: {} diff --git a/cli/push.ts b/cli/push.ts new file mode 100644 index 0000000..653771e --- /dev/null +++ b/cli/push.ts @@ -0,0 +1,320 @@ +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, + 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 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("#", ""); + 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 (text: string) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; +} + +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 = "development"; // 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); + } + } + + // 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 in ${envFileName}`), + ); + console.error(pc.gray(`Ensure ${envFileName} contains:`)); + console.error( + pc.gray(" FRAMER_PROJECT_URL=https://framer.com/projects/..."), + ); + process.exit(1); + } + + // Load config + 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); + 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 + let existingFilePaths: Set; + // 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 + 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.")); + // 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)}`)); + const result = await pushFiles( + filesToCreate, + filesToUpdate, + config.importReplacements, + (msg) => console.log(msg), + envTarget, + framer, + ); + + // Disconnect after push + try { + await framer.disconnect(); + } catch (err) { + 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); + + // 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"); + }); + }); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..11f072c --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": 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 => {