@reframejs/unframer 4.2.0-reframe.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +389 -0
  2. package/bin.js +5 -0
  3. package/dist/babel-jsx.d.ts +24 -0
  4. package/dist/babel-jsx.js +299 -0
  5. package/dist/babel-plugin-imports.d.ts +15 -0
  6. package/dist/babel-plugin-imports.js +211 -0
  7. package/dist/babel-typedoc.d.ts +39 -0
  8. package/dist/babel-typedoc.js +76 -0
  9. package/dist/bin.d.ts +2 -0
  10. package/dist/bin.js +3 -0
  11. package/dist/cli.d.ts +59 -0
  12. package/dist/cli.js +821 -0
  13. package/dist/compat.d.ts +2 -0
  14. package/dist/compat.js +23 -0
  15. package/dist/config.d.ts +18 -0
  16. package/dist/config.js +27 -0
  17. package/dist/css-core.d.ts +50 -0
  18. package/dist/css-core.js +231 -0
  19. package/dist/css.d.ts +15 -0
  20. package/dist/css.js +36 -0
  21. package/dist/esbuild.d.ts +31 -0
  22. package/dist/esbuild.js +317 -0
  23. package/dist/exporter.d.ts +96 -0
  24. package/dist/exporter.js +1151 -0
  25. package/dist/flat-cache-interceptor.d.ts +24 -0
  26. package/dist/flat-cache-interceptor.js +87 -0
  27. package/dist/framer-chunks/chunk-IKQSD2QC.js +10 -0
  28. package/dist/framer-chunks/fontshare-PZLWRK4B-MHMZIGTX.js +7 -0
  29. package/dist/framer-chunks/fontshare-SXU5BGFE-OWTMMPGS.js +7 -0
  30. package/dist/framer-chunks/fontshare-TIA7QUPT-PUDLUTQ7.js +7 -0
  31. package/dist/framer-chunks/framer-font-RD2SUPQH-Q4MS7WS6.js +7 -0
  32. package/dist/framer-chunks/google-EGNT223R-P4DUHBW2.js +7 -0
  33. package/dist/framer-chunks/google-H6SFY4F5-5JSJCGDR.js +7 -0
  34. package/dist/framer-chunks/google-YSYBFRE6-L7YAHH7V.js +7 -0
  35. package/dist/framer-client.server.d.ts +4 -0
  36. package/dist/framer-client.server.js +45 -0
  37. package/dist/framer-package-versions.d.ts +781 -0
  38. package/dist/framer-package-versions.js +782 -0
  39. package/dist/framer-types.d.ts +30 -0
  40. package/dist/framer-types.js +35 -0
  41. package/dist/framer.d.ts +4990 -0
  42. package/dist/framer.js +64296 -0
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +13 -0
  45. package/dist/package-manager.d.ts +10 -0
  46. package/dist/package-manager.js +138 -0
  47. package/dist/plugin-mcp-dist/lib/client-websocket.d.ts +5 -0
  48. package/dist/plugin-mcp-dist/lib/client-websocket.js +103 -0
  49. package/dist/plugin-mcp-dist/lib/cms.d.ts +10 -0
  50. package/dist/plugin-mcp-dist/lib/cms.js +58 -0
  51. package/dist/plugin-mcp-dist/lib/errors.d.ts +5 -0
  52. package/dist/plugin-mcp-dist/lib/errors.js +48 -0
  53. package/dist/plugin-mcp-dist/lib/framer-client.d.ts +2 -0
  54. package/dist/plugin-mcp-dist/lib/framer-client.js +4 -0
  55. package/dist/plugin-mcp-dist/lib/framer-client.server.d.ts +3 -0
  56. package/dist/plugin-mcp-dist/lib/framer-client.server.js +46 -0
  57. package/dist/plugin-mcp-dist/lib/framer.d.ts +30 -0
  58. package/dist/plugin-mcp-dist/lib/framer.js +1084 -0
  59. package/dist/plugin-mcp-dist/lib/hooks.d.ts +6 -0
  60. package/dist/plugin-mcp-dist/lib/hooks.js +46 -0
  61. package/dist/plugin-mcp-dist/lib/mcp-client.d.ts +142 -0
  62. package/dist/plugin-mcp-dist/lib/mcp-client.js +40 -0
  63. package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts +385 -0
  64. package/dist/plugin-mcp-dist/lib/mcp-handlers.js +1924 -0
  65. package/dist/plugin-mcp-dist/lib/mcp-tools.d.ts +5 -0
  66. package/dist/plugin-mcp-dist/lib/mcp-tools.js +5 -0
  67. package/dist/plugin-mcp-dist/lib/mcp-websocket.d.ts +10 -0
  68. package/dist/plugin-mcp-dist/lib/mcp-websocket.js +88 -0
  69. package/dist/plugin-mcp-dist/lib/plugin-websocket.d.ts +5 -0
  70. package/dist/plugin-mcp-dist/lib/plugin-websocket.js +170 -0
  71. package/dist/plugin-mcp-dist/lib/react-export.d.ts +51 -0
  72. package/dist/plugin-mcp-dist/lib/react-export.js +358 -0
  73. package/dist/plugin-mcp-dist/lib/schema.d.ts +276 -0
  74. package/dist/plugin-mcp-dist/lib/schema.js +907 -0
  75. package/dist/plugin-mcp-dist/lib/store.d.ts +2 -0
  76. package/dist/plugin-mcp-dist/lib/store.js +10 -0
  77. package/dist/plugin-mcp-dist/lib/tree-utils.d.ts +6 -0
  78. package/dist/plugin-mcp-dist/lib/tree-utils.js +141 -0
  79. package/dist/plugin-mcp-dist/lib/tunnel.d.ts +46 -0
  80. package/dist/plugin-mcp-dist/lib/tunnel.js +117 -0
  81. package/dist/plugin-mcp-dist/lib/types.d.ts +110 -0
  82. package/dist/plugin-mcp-dist/lib/types.js +188 -0
  83. package/dist/plugin-mcp-dist/lib/upstream-socket.d.ts +13 -0
  84. package/dist/plugin-mcp-dist/lib/upstream-socket.js +56 -0
  85. package/dist/plugin-mcp-dist/lib/utils.d.ts +13 -0
  86. package/dist/plugin-mcp-dist/lib/utils.js +57 -0
  87. package/dist/plugin-mcp-dist/lib/websocket-server.d.ts +10 -0
  88. package/dist/plugin-mcp-dist/lib/websocket-server.js +88 -0
  89. package/dist/plugin-mcp-dist/lib/xml.d.ts +10 -0
  90. package/dist/plugin-mcp-dist/lib/xml.js +395 -0
  91. package/dist/react.d.ts +76 -0
  92. package/dist/react.js +280 -0
  93. package/dist/renamer.d.ts +11 -0
  94. package/dist/renamer.js +139 -0
  95. package/dist/sentry.d.ts +2 -0
  96. package/dist/sentry.js +5 -0
  97. package/dist/stackblitz.d.ts +9 -0
  98. package/dist/stackblitz.js +179 -0
  99. package/dist/styles/framer.css +920 -0
  100. package/dist/styles/reset.css +37 -0
  101. package/dist/typescript.d.ts +24 -0
  102. package/dist/typescript.js +197 -0
  103. package/dist/undici-dispatcher.d.ts +2 -0
  104. package/dist/undici-dispatcher.js +10 -0
  105. package/dist/unframer-loader.d.ts +2 -0
  106. package/dist/unframer-loader.js +12 -0
  107. package/dist/utils.d.ts +20 -0
  108. package/dist/utils.js +81 -0
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.js +2 -0
  111. package/package.json +131 -0
package/dist/cli.js ADDED
@@ -0,0 +1,821 @@
1
+ import { z } from 'zod';
2
+ import { setMaxListeners } from 'events';
3
+ import pkg from '../package.json' with { type: 'json' };
4
+ import pico from 'picocolors';
5
+ const { blue, bgBlue, green } = pico;
6
+ import { fetch } from 'undici';
7
+ import './sentry.js';
8
+ import { input, select, password } from '@inquirer/prompts';
9
+ import { bundle, createExampleComponentCode } from './exporter.js';
10
+ import { generateStackblitzFiles } from './stackblitz.js';
11
+ import { goke, wrapJsonSchema } from 'goke';
12
+ import { exec } from 'child_process';
13
+ import { promisify } from 'util';
14
+ import fs from 'fs';
15
+ import path, { basename } from 'path';
16
+ import { defaultBreakpointSizes } from './css-core.js';
17
+ import { componentNameToPath, dedent, isTruthy, kebabCase, logger, sleep, spinner, } from './utils.js';
18
+ import { getPackageManager } from './package-manager.js';
19
+ import { notifyError } from './sentry.js';
20
+ import { dispatcher } from './undici-dispatcher.js';
21
+ import { loadConfig, saveConfig, getConfigPath, } from './config.js';
22
+ import { addMcpCommands } from '@goke/mcp';
23
+ const configNames = ['unframer.config.json', 'unframer.json'];
24
+ export const cli = goke('unframer');
25
+ let defaultOutDir = 'framer';
26
+ cli.command('[...projectIds]', 'Run unframer with one or more project IDs')
27
+ .option('--outDir <dir>', 'Output directory')
28
+ .option('--external [package]', 'Make some package external, do not pass a package name to make all packages external')
29
+ .option('--watch', 'Watch for changes and rebuild')
30
+ .option('--jsx', 'Output jsx code instead of minified .js code')
31
+ .option('--debug', 'Enable debug logging')
32
+ .option('--metafile', 'Generate meta.json file with build metadata')
33
+ .action(async function main(projectIds, options) {
34
+ const outDir = options.outDir || defaultOutDir;
35
+ const jsx = options.jsx ?? true;
36
+ const external_ = options.external;
37
+ // --external without value gives "" (optional value); treat same as undefined (all external)
38
+ const allExternal = !external_ || external_ === '';
39
+ const externalPackages = external_ && external_ !== ''
40
+ ? [external_]
41
+ : [];
42
+ try {
43
+ if (options.debug) {
44
+ logger.debug = true;
45
+ }
46
+ const controller = new AbortController();
47
+ const signal = controller.signal;
48
+ const watch = options.watch;
49
+ if (projectIds.length > 0) {
50
+ if (watch && projectIds.length > 1) {
51
+ console.error('--watch is only supported with a single project ID');
52
+ process.exit(1);
53
+ }
54
+ // Fetch configs for all project IDs
55
+ const fetchResults = await Promise.all(projectIds.map((projectId) => {
56
+ return configFromFetch({
57
+ allExternal,
58
+ externalPackages,
59
+ outDir,
60
+ projectId,
61
+ });
62
+ }));
63
+ // Merge configs when multiple projects are provided, otherwise use single config directly
64
+ const mergedConfig = fetchResults.length === 1
65
+ ? fetchResults[0].config
66
+ : mergeConfigs(fetchResults.map((r) => r.config));
67
+ const cwd = fetchResults[0].cwd;
68
+ const websiteUrl = fetchResults[0].websiteUrl;
69
+ const { rebuild, buildContext } = await bundle({
70
+ config: {
71
+ jsx,
72
+ ...mergedConfig,
73
+ },
74
+ watch,
75
+ cwd,
76
+ signal,
77
+ metafile: options.metafile,
78
+ });
79
+ // console.log('buildContext', buildContext)
80
+ if (!websiteUrl || !options.watch) {
81
+ await buildContext?.dispose?.();
82
+ return;
83
+ }
84
+ spinner.start(`Waiting for changes, try editing a component in Framer and click publish...`);
85
+ let lastEtag = null;
86
+ const startTime = Date.now();
87
+ while (Date.now() - startTime < 30 * 60 * 1000) {
88
+ const etag = await fetch(websiteUrl, {
89
+ method: 'HEAD',
90
+ dispatcher,
91
+ })
92
+ .then((response) => response.headers.get('etag'))
93
+ .catch((error) => {
94
+ logger.error('Error fetching etag:', error);
95
+ return null;
96
+ });
97
+ logger.log('etag', etag);
98
+ if (etag && lastEtag && etag !== lastEtag) {
99
+ spinner.start(`Detected Framer website change, rebuilding...`);
100
+ lastEtag = etag;
101
+ await rebuild();
102
+ }
103
+ if (etag) {
104
+ lastEtag = etag;
105
+ }
106
+ await sleep(1000 * 2);
107
+ }
108
+ }
109
+ // legacy behavior without Framer plugin
110
+ fixOldUnframerPath();
111
+ const cwd = process.cwd();
112
+ logger.log(`Looking for ${configNames.join(', ')} in ${cwd}`);
113
+ const configPath = findUp(configNames, { cwd });
114
+ if (!configPath) {
115
+ logger.log(`No ${configNames.join(', ')} found`);
116
+ return;
117
+ }
118
+ const configBasename = basename(configPath);
119
+ const configContent = fs.readFileSync(configPath, 'utf8');
120
+ if (!configContent) {
121
+ logger.log(`No ${configBasename} contents found`);
122
+ return;
123
+ }
124
+ const configContentWithoutComments = configContent.replace(/^\s*\/\/.*$/gm, '');
125
+ const config = JSON.parse(configContentWithoutComments);
126
+ if (outDir !== defaultOutDir) {
127
+ config.outDir = outDir;
128
+ }
129
+ setMaxListeners(0, controller.signal);
130
+ const { buildContext } = await bundle({
131
+ config: { ...config, externalPackages, allExternal },
132
+ watch,
133
+ signal: controller.signal,
134
+ cwd: path.resolve(process.cwd(), config.outDir || 'framer'),
135
+ metafile: options.metafile,
136
+ });
137
+ await buildContext.dispose?.();
138
+ }
139
+ catch (error) {
140
+ notifyError(error);
141
+ throw error;
142
+ }
143
+ });
144
+ function fixOldUnframerPath() {
145
+ // if unframer.json exists, rename it to unframer.config.json
146
+ const oldConfigPath = fs.existsSync('unframer.json');
147
+ if (oldConfigPath) {
148
+ fs.renameSync('unframer.json', 'unframer.config.json');
149
+ logger.green('legacy unframer.json config renamed to unframer.config.json');
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ const version = pkg.version;
155
+ cli.version(version).help();
156
+ cli.command('example-app <projectId>', 'Create an example app with Framer components')
157
+ .option('--outDir <dir>', 'Output directory')
158
+ .action(async (projectId, options) => {
159
+ if (!projectId?.trim()) {
160
+ console.log(`unframer example-app requires a project id positional param`);
161
+ process.exit(1);
162
+ }
163
+ try {
164
+ const outDir = options.outDir || 'example-unframer-app';
165
+ console.log(`Creating example app in ${outDir}`);
166
+ // Create the output directory
167
+ const absoluteOutDir = path.resolve(process.cwd(), outDir);
168
+ if (!fs.existsSync(absoluteOutDir)) {
169
+ fs.mkdirSync(absoluteOutDir, { recursive: true });
170
+ }
171
+ // Fetch the project configuration
172
+ spinner.start('Fetching project configuration...');
173
+ const { config, websiteUrl } = await configFromFetch({
174
+ projectId,
175
+ allExternal: true,
176
+ externalPackages: [],
177
+ outDir: 'src/framer',
178
+ });
179
+ spinner.stop('Project configuration fetched');
180
+ // Generate example component code
181
+ spinner.start('Generating example component code...');
182
+ const { exampleCode } = await createExampleComponentCode({
183
+ outDir: './framer',
184
+ config,
185
+ });
186
+ // Generate all the files needed for the example app
187
+ const files = generateStackblitzFiles({
188
+ projectId,
189
+ appComponentCode: exampleCode,
190
+ title: config.projectName || 'Unframer Example App',
191
+ });
192
+ // Write all files to the output directory
193
+ for (const file of files) {
194
+ const filePath = path.join(absoluteOutDir, file.relativePath);
195
+ const fileDir = path.dirname(filePath);
196
+ // Ensure directory exists
197
+ if (!fs.existsSync(fileDir)) {
198
+ fs.mkdirSync(fileDir, { recursive: true });
199
+ }
200
+ fs.writeFileSync(filePath, file.contents);
201
+ console.log(`Created ${file.relativePath}`);
202
+ }
203
+ spinner.stop('Example files created');
204
+ // Bundle the Framer components
205
+ spinner.start('Downloading Framer components...');
206
+ const componentsOutDir = path.join(absoluteOutDir, 'src/framer');
207
+ const { buildContext } = await bundle({
208
+ config: {
209
+ ...config,
210
+ jsx: true,
211
+ outDir: componentsOutDir,
212
+ allExternal: true,
213
+ externalPackages: [],
214
+ },
215
+ watch: false,
216
+ cwd: componentsOutDir,
217
+ signal: new AbortController().signal,
218
+ metafile: false,
219
+ });
220
+ await buildContext?.dispose?.();
221
+ spinner.stop('Framer components downloaded');
222
+ // Install dependencies using detected package manager
223
+ spinner.stop('Framer components downloaded');
224
+ const packageManager = await getPackageManager();
225
+ const installCommand = packageManager === 'yarn' ? 'yarn' : `${packageManager} install`;
226
+ console.log(`Installing dependencies with ${packageManager}...`);
227
+ spinner.start(`Running ${installCommand}...`);
228
+ const execAsync = promisify(exec);
229
+ try {
230
+ await execAsync(installCommand, {
231
+ cwd: absoluteOutDir,
232
+ // Can't use 'inherit' with async exec, so we'll capture output
233
+ encoding: 'utf8',
234
+ });
235
+ spinner.stop('Dependencies installed successfully');
236
+ }
237
+ catch (error) {
238
+ spinner.stop('Failed to install dependencies');
239
+ console.error(`${packageManager} install failed:`, error?.message || error);
240
+ console.log(`You can manually run "${installCommand}" in the created directory`);
241
+ }
242
+ console.info(dedent `
243
+
244
+ Example app created successfully in ${outDir}!
245
+
246
+ Next steps:
247
+ cd ${outDir}
248
+ ${packageManager} run dev
249
+
250
+ Quick guide:
251
+ ▪︎ Read and edit src/App.tsx to add or customize your rendered Framer components
252
+ ▪︎ Your components are in src/framer/ directory. Run npm run framer to sync changes from Framer
253
+ ▪︎ The app uses Vite + React + TypeScript + Tailwind CSS
254
+
255
+ `);
256
+ }
257
+ catch (error) {
258
+ notifyError(error);
259
+ spinner.error('Failed to create example app');
260
+ throw error;
261
+ }
262
+ });
263
+ cli.command('mcp login [url]', 'Login to Framer MCP. Choose between plugin mode (requires Framer open) or server API mode (works headlessly with API key).').action(async (url) => {
264
+ try {
265
+ // If URL is passed directly, use plugin mode
266
+ if (url) {
267
+ saveConfig({ mode: 'plugin', mcpUrl: url });
268
+ console.log(`MCP URL saved to ${getConfigPath()}`);
269
+ console.log(`Run \`unframer --help\` to see all available MCP commands`);
270
+ return;
271
+ }
272
+ // Show mode selection dropdown
273
+ const mode = await select({
274
+ message: 'Select authentication mode:',
275
+ choices: [
276
+ {
277
+ value: 'plugin',
278
+ name: 'MCP Plugin (Recommended)',
279
+ description: 'Requires Framer to be open with MCP plugin running. Paste URL from plugin.',
280
+ },
281
+ {
282
+ value: 'server-api',
283
+ name: 'Server API',
284
+ description: 'Works without Framer open. Requires API key from project settings.',
285
+ },
286
+ ],
287
+ });
288
+ if (mode === 'plugin') {
289
+ const shortcut = process.platform === 'darwin' ? 'Cmd+K' : 'Ctrl+K';
290
+ console.log('\nTo get your MCP URL:');
291
+ console.log(' 1. Go to https://framer.com and open your project');
292
+ console.log(` 2. Press ${shortcut} and search for "MCP" plugin`);
293
+ console.log(' 3. Copy the URL shown in the plugin\n');
294
+ const mcpUrl = await input({ message: 'Paste MCP URL:' });
295
+ if (!mcpUrl) {
296
+ console.error('MCP URL is required');
297
+ process.exit(1);
298
+ }
299
+ saveConfig({ mode: 'plugin', mcpUrl });
300
+ console.log(`\nMCP URL saved to ${getConfigPath()}`);
301
+ console.log(`Run \`unframer --help\` to see all available MCP commands`);
302
+ }
303
+ else {
304
+ // Server API mode
305
+ console.log('\nTo get your API key:');
306
+ console.log(' 1. Open your Framer project at https://framer.com/projects');
307
+ console.log(' 2. Go to Project Settings > API');
308
+ console.log(' 3. Generate or copy your API key\n');
309
+ const apiKey = await password({ message: 'Enter Framer API key:', mask: '*' });
310
+ if (!apiKey) {
311
+ console.error('API key is required');
312
+ process.exit(1);
313
+ }
314
+ console.log('\nTo get your project URL:');
315
+ console.log(' Copy the URL from your browser when viewing the project');
316
+ console.log(' Example: https://framer.com/projects/MyProject--abc123\n');
317
+ const projectUrl = await input({
318
+ message: 'Enter Framer project URL (optional, can use --project later):',
319
+ });
320
+ saveConfig({
321
+ mode: 'server-api',
322
+ framerApiKey: apiKey,
323
+ framerProjectUrl: projectUrl || undefined,
324
+ });
325
+ console.log(`\nServer API credentials saved to ${getConfigPath()}`);
326
+ console.log(`\nUsage:`);
327
+ if (projectUrl) {
328
+ console.log(` unframer mcp getProjectXml`);
329
+ }
330
+ else {
331
+ console.log(` unframer mcp getProjectXml --project "https://framer.com/projects/..."`);
332
+ }
333
+ console.log(`\nOr set FRAMER_PROJECT_URL environment variable`);
334
+ }
335
+ }
336
+ catch (error) {
337
+ if (error instanceof Error && error.name === 'ExitPromptError') {
338
+ process.exit(0);
339
+ }
340
+ throw error;
341
+ }
342
+ });
343
+ // Add MCP tool commands
344
+ const config = loadConfig();
345
+ const cliArgs = process.argv.slice(2);
346
+ const hasMcpCommand = cliArgs.includes('mcp');
347
+ const hasProjectOption = cliArgs.some((arg) => {
348
+ return arg === '--project' || arg.startsWith('--project=');
349
+ });
350
+ const hasMcpEvalCommand = hasMcpCommand && cliArgs.includes('eval');
351
+ const hasServerApiEnv = Boolean(process.env.FRAMER_PROJECT_URL || process.env.FRAMER_API_KEY);
352
+ const shouldUseServerApi = hasMcpCommand && (hasProjectOption || hasMcpEvalCommand || hasServerApiEnv);
353
+ const mcpMode = shouldUseServerApi
354
+ ? 'server-api'
355
+ : config.mode || (config.mcpUrl ? 'plugin' : undefined);
356
+ if (mcpMode === 'server-api') {
357
+ // Server API mode - use framer-api directly
358
+ await registerServerApiCommands();
359
+ }
360
+ else {
361
+ // Plugin mode - use MCP transport
362
+ await addMcpCommands({
363
+ cli,
364
+ commandPrefix: 'mcp',
365
+ getMcpTransport: async (sessionId) => {
366
+ const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
367
+ // UNFRAMER_MCP_URL env var overrides config file (contains full URL with auth)
368
+ const mcpUrl = process.env.UNFRAMER_MCP_URL || loadConfig().mcpUrl;
369
+ if (!mcpUrl) {
370
+ return null;
371
+ }
372
+ const url = new URL(mcpUrl);
373
+ // Use /mcp endpoint for StreamableHTTP
374
+ if (url.pathname.endsWith('/sse')) {
375
+ url.pathname = url.pathname.replace(/\/sse$/, '/mcp');
376
+ }
377
+ return new StreamableHTTPClientTransport(url, { sessionId });
378
+ },
379
+ loadCache: () => {
380
+ return loadConfig().cachedMcpTools;
381
+ },
382
+ saveCache: (cache) => {
383
+ const configNow = loadConfig();
384
+ saveConfig({ ...configNow, cachedMcpTools: cache });
385
+ },
386
+ }).catch((e) => console.error(e));
387
+ }
388
+ /**
389
+ * Register MCP commands for server-api mode using framer-api directly.
390
+ * This bypasses the MCP transport and calls handlers directly.
391
+ */
392
+ async function registerServerApiCommands() {
393
+ // Dynamic import to avoid loading framer-api in plugin mode
394
+ const { connect } = await import('framer-api');
395
+ // `mcp eval` — run arbitrary framer-api code against a project.
396
+ // Useful for testing API calls before implementing them in MCP tools.
397
+ // The `framer` client is available as a local variable inside the evaluated code.
398
+ cli.command('mcp eval [code]', 'Evaluate arbitrary framer-api code against a project')
399
+ .option('--project <url>', 'Framer project URL. Also reads FRAMER_PROJECT_URL env var.')
400
+ .action(async (code, options) => {
401
+ // Read code from argument or stdin
402
+ let evalCode = code;
403
+ if (!evalCode && !process.stdin.isTTY) {
404
+ const chunks = [];
405
+ for await (const chunk of process.stdin) {
406
+ chunks.push(chunk);
407
+ }
408
+ evalCode = Buffer.concat(chunks).toString('utf-8').trim();
409
+ }
410
+ if (!evalCode) {
411
+ console.error('No code provided. Pass as argument or pipe via stdin.');
412
+ process.exit(1);
413
+ }
414
+ const projectUrl = options.project ||
415
+ process.env.FRAMER_PROJECT_URL ||
416
+ loadConfig().framerProjectUrl;
417
+ const apiKey = process.env.FRAMER_API_KEY || loadConfig().framerApiKey;
418
+ if (!projectUrl) {
419
+ console.error('Project URL required. Use --project option, FRAMER_PROJECT_URL env var, or set during login.');
420
+ process.exit(1);
421
+ }
422
+ if (!apiKey) {
423
+ console.error('API key required. Set FRAMER_API_KEY env var or run `unframer mcp login` first.');
424
+ process.exit(1);
425
+ }
426
+ let framerClient;
427
+ let actionError;
428
+ try {
429
+ spinner.start('Connecting to Framer...');
430
+ framerClient = await connect(projectUrl, apiKey);
431
+ spinner.stop('Connected');
432
+ // Build an async function body. If the code is a single expression
433
+ // (no semicolons and no newlines), wrap it as a return so the result is captured.
434
+ const isExpression = !evalCode.includes(';') && !evalCode.includes('\n');
435
+ const functionBody = isExpression
436
+ ? `return (${evalCode})`
437
+ : evalCode;
438
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
439
+ const fn = new AsyncFunction('framer', functionBody);
440
+ const result = await fn(framerClient);
441
+ if (result !== undefined) {
442
+ const output = (() => {
443
+ try {
444
+ return JSON.stringify(result, null, 2);
445
+ }
446
+ catch {
447
+ return String(result);
448
+ }
449
+ })();
450
+ console.log(output);
451
+ }
452
+ }
453
+ catch (error) {
454
+ actionError = error;
455
+ }
456
+ finally {
457
+ if (framerClient) {
458
+ try {
459
+ await framerClient.disconnect();
460
+ }
461
+ catch (disconnectError) {
462
+ logger.error('Failed disconnecting from Framer:', disconnectError);
463
+ }
464
+ }
465
+ }
466
+ if (actionError) {
467
+ console.error('Eval failed:', actionError instanceof Error ? actionError.message : actionError);
468
+ if (actionError instanceof Error && actionError.stack) {
469
+ console.error(actionError.stack);
470
+ }
471
+ process.exit(1);
472
+ }
473
+ });
474
+ // Import tool definitions and handler from plugin-mcp
475
+ // Note: Run `pnpm --filter plugin-mcp build && pnpm --filter plugin-mcp gen-unframer` first
476
+ let mcpTools;
477
+ let mcpToolHandler;
478
+ try {
479
+ const imported = await import('./plugin-mcp-dist/lib/mcp-handlers.js');
480
+ mcpTools = imported.mcpTools;
481
+ mcpToolHandler = imported.mcpToolHandler;
482
+ }
483
+ catch (error) {
484
+ if (hasMcpEvalCommand) {
485
+ // eval command only needs framer-api, not plugin-mcp handlers
486
+ logger.log('Could not load plugin-mcp handlers:', error instanceof Error ? error.message : error);
487
+ }
488
+ else {
489
+ throw new Error('Failed to load plugin-mcp handlers. Run: pnpm --filter plugin-mcp tsc --incremental && pnpm --filter plugin-mcp gen-unframer', { cause: error });
490
+ }
491
+ }
492
+ if (!mcpTools || !mcpToolHandler) {
493
+ return;
494
+ }
495
+ // Register a command for each MCP tool
496
+ for (const [toolName, toolDef] of Object.entries(mcpTools)) {
497
+ const cmd = cli.command(`mcp ${String(toolName)}`, toolDef.description.split('\n')[0]);
498
+ cmd.option('--project <url>', 'Framer project URL. Uses server-api mode (framer-api headless). Works alongside plugin mode login, pass --project to switch to server-api for a single command. Also reads FRAMER_PROJECT_URL env var.');
499
+ // Add options based on tool input schema, using zod v4 native JSON Schema conversion
500
+ const inputSchema = toolDef.input;
501
+ if (inputSchema) {
502
+ const jsonSchema = z.toJSONSchema(inputSchema);
503
+ const properties = jsonSchema.properties || {};
504
+ const required = new Set(jsonSchema.required || []);
505
+ for (const [key, prop] of Object.entries(properties)) {
506
+ const isRequired = required.has(key);
507
+ const isBooleanType = prop.type === 'boolean';
508
+ const optionName = isBooleanType
509
+ ? `--${key}`
510
+ : `--${key} <value>`;
511
+ const optionDescription = [
512
+ prop.description || key,
513
+ isRequired ? '(required)' : '',
514
+ ]
515
+ .filter(Boolean)
516
+ .join(' ');
517
+ if (isBooleanType && prop.default === undefined) {
518
+ cmd.option(optionName, optionDescription);
519
+ continue;
520
+ }
521
+ cmd.option(optionName, wrapJsonSchema({
522
+ ...prop,
523
+ description: optionDescription,
524
+ }));
525
+ }
526
+ }
527
+ cmd.action(async (options) => {
528
+ const projectOption = typeof options.project === 'string'
529
+ ? options.project
530
+ : undefined;
531
+ const projectUrl = projectOption ||
532
+ process.env.FRAMER_PROJECT_URL ||
533
+ loadConfig().framerProjectUrl;
534
+ const apiKey = process.env.FRAMER_API_KEY || loadConfig().framerApiKey;
535
+ if (!projectUrl) {
536
+ console.error('Project URL required. Use --project option, FRAMER_PROJECT_URL env var, or set during login.');
537
+ process.exit(1);
538
+ }
539
+ if (!apiKey) {
540
+ console.error('API key required. Set FRAMER_API_KEY env var or run `unframer mcp login` first.');
541
+ process.exit(1);
542
+ }
543
+ // Remove CLI-specific options from input
544
+ const toolInput = Object.fromEntries(Object.entries(options).filter(([key]) => {
545
+ return key !== 'project';
546
+ }));
547
+ const globalWithFramer = globalThis;
548
+ let framerClient = undefined;
549
+ let actionError = undefined;
550
+ try {
551
+ spinner.start(`Connecting to Framer...`);
552
+ framerClient = await connect(projectUrl, apiKey);
553
+ // Set global framer for utility functions that use it
554
+ globalWithFramer.framer = framerClient;
555
+ spinner.start(`Running ${String(toolName)}...`);
556
+ const result = await mcpToolHandler({
557
+ type: toolName,
558
+ input: toolInput,
559
+ });
560
+ spinner.stop('');
561
+ // Output result
562
+ if (typeof result === 'string') {
563
+ console.log(result);
564
+ }
565
+ else {
566
+ console.log(JSON.stringify(result, null, 2));
567
+ }
568
+ }
569
+ catch (error) {
570
+ actionError = error;
571
+ }
572
+ finally {
573
+ if (framerClient) {
574
+ try {
575
+ await framerClient.disconnect();
576
+ }
577
+ catch (disconnectError) {
578
+ logger.error('Failed disconnecting from Framer:', disconnectError);
579
+ }
580
+ }
581
+ delete globalWithFramer.framer;
582
+ }
583
+ if (actionError) {
584
+ spinner.error(`Failed: ${actionError instanceof Error ? actionError.message : String(actionError)}`);
585
+ process.exit(1);
586
+ }
587
+ });
588
+ }
589
+ }
590
+ /**
591
+ * Merge multiple project configs into a single Config so esbuild bundles all
592
+ * components together and deduplicates shared chunks automatically.
593
+ *
594
+ * - Components maps are merged; on name collision the second project's component
595
+ * is prefixed with the project name to disambiguate.
596
+ * - First project's projectId/projectName/fullFramerProjectId are used as the
597
+ * primary identifiers (framerSiteId in ContextProviders).
598
+ * - Arrays (componentBreakpoints, tokens, locales, framerWebPages, etc.) are concatenated.
599
+ */
600
+ function mergeConfigs(configs) {
601
+ if (configs.length === 0) {
602
+ throw new Error('mergeConfigs called with empty array');
603
+ }
604
+ if (configs.length === 1) {
605
+ return configs[0];
606
+ }
607
+ const first = configs[0];
608
+ // Merge components maps, disambiguating name collisions.
609
+ // Track rename maps per config so we can update breakpoints and instances.
610
+ const mergedComponents = {};
611
+ const renameMaps = [];
612
+ for (const config of configs) {
613
+ const projectSlug = kebabCase(config.projectName || config.projectId || '');
614
+ const renameMap = new Map();
615
+ renameMaps.push(renameMap);
616
+ for (const [name, url] of Object.entries(config.components)) {
617
+ if (mergedComponents[name]) {
618
+ // Name collision: prefix with project slug, ensure uniqueness with numeric suffix
619
+ const base = projectSlug ? `${projectSlug}/${name}` : `${config.projectId}/${name}`;
620
+ const disambiguated = uniqueComponentName(base, mergedComponents);
621
+ logger.log(`Component name collision for "${name}", renaming to "${disambiguated}"`);
622
+ renameMap.set(name, disambiguated);
623
+ mergedComponents[disambiguated] = url;
624
+ }
625
+ else {
626
+ mergedComponents[name] = url;
627
+ }
628
+ }
629
+ }
630
+ // Merge componentBreakpoints, updating componentName for any renamed components
631
+ const mergedBreakpoints = configs.flatMap((config, i) => {
632
+ const renameMap = renameMaps[i];
633
+ return (config.componentBreakpoints || []).map((bp) => {
634
+ const renamed = renameMap.get(bp.componentName);
635
+ if (renamed) {
636
+ return { ...bp, componentName: renamed };
637
+ }
638
+ return bp;
639
+ });
640
+ });
641
+ // Merge tokens, deduplicate by id (keep first occurrence)
642
+ const seenTokenIds = new Set();
643
+ const mergedTokens = configs.flatMap((config) => {
644
+ return (config.tokens || []).filter((token) => {
645
+ if (seenTokenIds.has(token.id)) {
646
+ return false;
647
+ }
648
+ seenTokenIds.add(token.id);
649
+ return true;
650
+ });
651
+ });
652
+ // Merge locales, deduplicate by code
653
+ const seenLocaleCodes = new Set();
654
+ const mergedLocales = configs.flatMap((config) => {
655
+ return (config.locales || []).filter((locale) => {
656
+ if (seenLocaleCodes.has(locale.code)) {
657
+ return false;
658
+ }
659
+ seenLocaleCodes.add(locale.code);
660
+ return true;
661
+ });
662
+ });
663
+ // Merge framerWebPages
664
+ const mergedWebPages = configs.flatMap((config) => {
665
+ return config.framerWebPages || [];
666
+ });
667
+ // Merge componentInstancesInIndexPage, updating componentPathSlug for renamed components
668
+ const mergedInstances = configs.flatMap((config, i) => {
669
+ const renameMap = renameMaps[i];
670
+ return (config.componentInstancesInIndexPage || []).map((instance) => {
671
+ const renamed = renameMap.get(instance.componentPathSlug);
672
+ if (renamed) {
673
+ return { ...instance, componentPathSlug: renamed };
674
+ }
675
+ return instance;
676
+ });
677
+ }).sort((a, b) => {
678
+ return a.pageOrdering - b.pageOrdering;
679
+ });
680
+ const projectNames = configs
681
+ .map((c) => c.projectName)
682
+ .filter(isTruthy);
683
+ spinner.info(`Merging ${configs.length} projects: ${projectNames.join(', ')}`);
684
+ return {
685
+ ...first,
686
+ components: mergedComponents,
687
+ componentBreakpoints: mergedBreakpoints,
688
+ tokens: mergedTokens,
689
+ locales: mergedLocales,
690
+ framerWebPages: mergedWebPages,
691
+ componentInstancesInIndexPage: mergedInstances,
692
+ };
693
+ }
694
+ /** Generate a unique component name by appending a numeric suffix if the base already exists */
695
+ function uniqueComponentName(base, existing) {
696
+ if (!existing[base]) {
697
+ return base;
698
+ }
699
+ let index = 2;
700
+ while (existing[`${base}-${index}`]) {
701
+ index++;
702
+ }
703
+ return `${base}-${index}`;
704
+ }
705
+ export async function configFromFetch({ projectId, externalPackages = [], allExternal = false, agent = '', outDir = undefined, }) {
706
+ logger.log(`Fetching config for project ${projectId}`);
707
+ const baseUrl = (process.env.UNFRAMER_SERVER_URL || 'https://unframer.co').replace(/\/$/, '');
708
+ if (process.env.UNFRAMER_SERVER_URL) {
709
+ console.log(`using server url ${baseUrl}`);
710
+ }
711
+ spinner.start(`Fetching config for project ${projectId}`);
712
+ const agentHeader = agent ||
713
+ (process.env.GITHUB_ACTIONS === 'true' ? 'github-actions' : 'cli');
714
+ const response = await fetch(`${baseUrl}/api/plugins/reactExportPlugin/project/${encodeURIComponent(projectId)}`, { headers: { 'X-Agent': agentHeader } });
715
+ const text = await response.text();
716
+ const parsed = (() => {
717
+ try {
718
+ return JSON.parse(text);
719
+ }
720
+ catch {
721
+ return text;
722
+ }
723
+ })();
724
+ if (!response.ok) {
725
+ if (response.status === 402) {
726
+ const buyUrl = parsed?.buyUrl;
727
+ const message = (() => {
728
+ if (typeof parsed === 'object' && parsed?.message) {
729
+ return String(parsed.message);
730
+ }
731
+ return 'A React Export subscription is required to download components.';
732
+ })();
733
+ spinner.error(message);
734
+ console.info('');
735
+ console.info('Go to this url to buy the Framer React Export subscription:\n');
736
+ console.info(green(buyUrl));
737
+ console.info();
738
+ process.exit(1);
739
+ }
740
+ spinner.error('Error fetching project data:');
741
+ console.error(parsed);
742
+ throw new Error(`HTTP ${response.status}`, { cause: parsed });
743
+ }
744
+ const data = parsed;
745
+ spinner.info(`Got Framer project data`);
746
+ const websiteUrl = data?.project?.websiteUrl;
747
+ const projectName = data?.project?.projectName || '';
748
+ if (projectName) {
749
+ spinner.info(`Using project: ${projectName}`);
750
+ }
751
+ let cwd = path.resolve(process.cwd(), outDir || 'framer');
752
+ const indexPage = data?.framerWebPages?.find((x) => x.path === '/');
753
+ const componentInstancesInIndexPage = data.componentInstances
754
+ ?.filter((x) => x.webPageId === indexPage?.webPageId)
755
+ .map((x) => {
756
+ const component = data.components.find((c) => {
757
+ return x.componentId === c.id;
758
+ });
759
+ if (!component) {
760
+ console.error(new Error(`cannot find component for instance ${x.componentId}`));
761
+ }
762
+ const componentPathSlug = componentNameToPath(component?.name || '');
763
+ const res = {
764
+ ...x,
765
+ controls: x.controls,
766
+ componentPathSlug,
767
+ };
768
+ return res;
769
+ })
770
+ .sort((a, b) => {
771
+ return a.pageOrdering - b.pageOrdering;
772
+ }) || [];
773
+ const config = {
774
+ ...data,
775
+ pageBackgroundColor: data.project?.pageBackgroundColor || '',
776
+ breakpoints: defaultBreakpointSizes,
777
+ outDir,
778
+ externalPackages,
779
+ allExternal,
780
+ projectId: data?.project?.projectId,
781
+ projectName,
782
+ fullFramerProjectId: data?.project?.fullFramerProjectId,
783
+ locales: data?.locales,
784
+ components: Object.fromEntries(data.components.map((c) => [componentNameToPath(c.name), c.url])),
785
+ componentBreakpoints: data.breakpoints
786
+ ?.map((b) => {
787
+ const c = data.components.find((c) => c.id === b.componentId);
788
+ if (!c) {
789
+ return;
790
+ }
791
+ return {
792
+ ...b,
793
+ componentName: componentNameToPath(c.name),
794
+ };
795
+ })
796
+ .filter(isTruthy) || [],
797
+ tokens: data.colorStyles,
798
+ componentInstancesInIndexPage,
799
+ framerWebPages: data.framerWebPages || [],
800
+ };
801
+ return { websiteUrl, cwd, config };
802
+ }
803
+ function findUp(configNames, { cwd }) {
804
+ let currentDir = cwd;
805
+ while (true) {
806
+ for (const configName of configNames) {
807
+ const configPath = path.join(currentDir, configName);
808
+ if (fs.existsSync(configPath)) {
809
+ return configPath;
810
+ }
811
+ }
812
+ const parentDir = path.dirname(currentDir);
813
+ if (parentDir === currentDir) {
814
+ // Reached the root directory
815
+ break;
816
+ }
817
+ currentDir = parentDir;
818
+ }
819
+ return null;
820
+ }
821
+ //# sourceMappingURL=cli.js.map