@createlex/figgen 1.4.2
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.
- package/README.md +164 -0
- package/bin/figgen.js +156 -0
- package/companion/bridge-server.cjs +786 -0
- package/companion/createlex-auth.cjs +364 -0
- package/companion/local-llm-generator.cjs +437 -0
- package/companion/login.mjs +189 -0
- package/companion/mcp-server.mjs +1365 -0
- package/companion/package.json +17 -0
- package/companion/server.js +65 -0
- package/companion/setup.cjs +309 -0
- package/companion/xcode-writer.cjs +516 -0
- package/package.json +50 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* local-llm-generator.cjs
|
|
5
|
+
*
|
|
6
|
+
* BYOK (Bring Your Own Key) SwiftUI generation.
|
|
7
|
+
* Uses ANTHROPIC_API_KEY, OPENAI_API_KEY, or an OpenAI-compatible
|
|
8
|
+
* base URL (e.g. Ollama, LM Studio) to generate SwiftUI without
|
|
9
|
+
* touching the CreateLex hosted backend.
|
|
10
|
+
*
|
|
11
|
+
* Required env vars (at least one):
|
|
12
|
+
* ANTHROPIC_API_KEY — use Claude (recommended)
|
|
13
|
+
* OPENAI_API_KEY — use OpenAI or compatible API
|
|
14
|
+
*
|
|
15
|
+
* Optional:
|
|
16
|
+
* OPENAI_MODEL — model name (default: gpt-4o)
|
|
17
|
+
* OPENAI_BASE_URL — custom base URL for Ollama / LM Studio etc.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Prompt constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const SWIFTUI_SYSTEM_PROMPT = `You are an expert SwiftUI engineer generating production-ready iOS code from Figma design context JSON.
|
|
25
|
+
|
|
26
|
+
RULES:
|
|
27
|
+
1. Output ONLY valid Swift code inside <file name="StructName.swift"> XML tags — one tag per file.
|
|
28
|
+
2. Always import SwiftUI. Never import UIKit or use UIViewRepresentable.
|
|
29
|
+
3. Map Figma layoutMode: HORIZONTAL→HStack, VERTICAL→VStack, NONE→ZStack/overlay.
|
|
30
|
+
4. Use the struct name supplied in OUTPUT_STRUCT_NAME for the primary view.
|
|
31
|
+
5. Responsive sizing: follow every _responsiveHint annotation in the node tree.
|
|
32
|
+
6. For colors: use Color extension token names when a styleName is present; otherwise Color(red:green:blue:).
|
|
33
|
+
7. Generate @State vars for every interactive element (TextField, Toggle, Button, Picker).
|
|
34
|
+
8. Add .accessibilityLabel() and .accessibilityHint() derived from node name and text content.
|
|
35
|
+
9. End every file with a #Preview { StructName() } block.
|
|
36
|
+
10. Emit best-effort code for anything complex — never emit TODO comments or placeholder stubs.
|
|
37
|
+
11. If reusableComponents are present, output each as a separate <file name="ComponentName.swift"> tag.
|
|
38
|
+
12. FONTS: Always use .font(.system(size: X, weight: .bold)) — NEVER reference custom font names like "Inter-Bold", "Roboto", or any fontName from Figma. Custom fonts are not bundled in the Xcode project and will cause runtime errors. System font only.
|
|
39
|
+
13. IMAGES: Every node listed in assetExportPlan MUST be referenced as Image("assetName").resizable().scaledToFill() — NEVER replace with Rectangle(), Color, or shapes. These PNGs are pre-exported to Assets.xcassets.
|
|
40
|
+
14. BLEND MODES: If an assetExportPlan candidate has a non-null blendModeSwiftUI value, you MUST append that modifier to the Image() call, e.g.: Image("Mockup_GroupView2").resizable().scaledToFit().frame(...).blendMode(.multiply). Without this, the image background will not blend with the dark frame background and will show as an opaque rectangle. Map: MULTIPLY→.multiply, SCREEN→.screen, OVERLAY→.overlay, DARKEN→.darken, LIGHTEN→.lighten, COLOR_DODGE→.colorDodge, COLOR_BURN→.colorBurn, HARD_LIGHT→.hardLight, SOFT_LIGHT→.softLight, DIFFERENCE→.difference, EXCLUSION→.exclusion.
|
|
41
|
+
|
|
42
|
+
RESPONSIVE LAYOUT RULES:
|
|
43
|
+
- Root frame with FILL sizing → .frame(maxWidth: .infinity)
|
|
44
|
+
- Font sizes → @ScaledMetric var: e.g. @ScaledMetric var titleSize: CGFloat = 34
|
|
45
|
+
- Horizontal scrolling children → ScrollView(.horizontal, showsIndicators: false)
|
|
46
|
+
- Vertical root scroll behavior → wrap body in ScrollView
|
|
47
|
+
- ViewThatFits for HStack→VStack fallback on narrow devices when content may wrap
|
|
48
|
+
- VStack(spacing:) / HStack(spacing:) from Figma itemSpacing — not fixed .padding() for inter-item spacing
|
|
49
|
+
- .ignoresSafeArea() only for background color/image layers, never for foreground content
|
|
50
|
+
- Hardcoded values are acceptable only for: cornerRadius, icon sizes ≤24pt, stroke widths
|
|
51
|
+
|
|
52
|
+
DESIGN TOKENS:
|
|
53
|
+
If designTokens.colors or designTokens.fonts are non-empty, output a <file name="DesignTokens.swift"> containing:
|
|
54
|
+
extension Color { static let tokenName = Color(red: ..., green: ..., blue: ...) }
|
|
55
|
+
extension Font { static let tokenName = .system(size: ..., weight: ...) }
|
|
56
|
+
Then use these token names everywhere in the main view code.
|
|
57
|
+
|
|
58
|
+
REUSABLE COMPONENTS:
|
|
59
|
+
For each item in reusableComponents:
|
|
60
|
+
- Output a separate <file name="ComponentName.swift">
|
|
61
|
+
- Promote repeated values (text, colors, image names) into View parameters
|
|
62
|
+
- In the parent view, replace inline rendering with ComponentName(param: value)`;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Context helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
function cleanText(value) {
|
|
69
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sanitizeName(value) {
|
|
73
|
+
const words = cleanText(value)
|
|
74
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
75
|
+
.split(' ')
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (words.length === 0) return 'GeneratedView';
|
|
78
|
+
return words.map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getRootNode(metadata) {
|
|
82
|
+
if (!metadata || typeof metadata !== 'object') return null;
|
|
83
|
+
if (Array.isArray(metadata.nodes)) {
|
|
84
|
+
return metadata.nodes.length === 1 ? metadata.nodes[0] : null;
|
|
85
|
+
}
|
|
86
|
+
return metadata.type ? metadata : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function flattenNodes(node, limit = 200) {
|
|
90
|
+
const results = [];
|
|
91
|
+
const visit = (current) => {
|
|
92
|
+
if (!current || typeof current !== 'object' || results.length >= limit) return;
|
|
93
|
+
results.push(current);
|
|
94
|
+
if (Array.isArray(current.children)) {
|
|
95
|
+
current.children.forEach(visit);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
visit(node);
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function annotateResponsiveHints(node, rootWidth) {
|
|
103
|
+
if (!node || !rootWidth) return node;
|
|
104
|
+
const width = node?.geometry?.width;
|
|
105
|
+
if (typeof width === 'number' && width > 0) {
|
|
106
|
+
const frac = width / rootWidth;
|
|
107
|
+
if (frac > 0.5 && frac < 1.0) {
|
|
108
|
+
node._responsiveHint = `width is ~${Math.round(frac * 100)}% of root canvas — use .frame(maxWidth: .infinity) with horizontal padding`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(node.children)) {
|
|
112
|
+
node.children.forEach((child) => annotateResponsiveHints(child, rootWidth));
|
|
113
|
+
}
|
|
114
|
+
return node;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pruneNodeTree(root, maxNodes) {
|
|
118
|
+
const all = flattenNodes(root, maxNodes);
|
|
119
|
+
const ids = new Set(all.map((n) => n.id));
|
|
120
|
+
|
|
121
|
+
function prune(node) {
|
|
122
|
+
if (!node) return node;
|
|
123
|
+
const pruned = { ...node };
|
|
124
|
+
if (Array.isArray(pruned.children)) {
|
|
125
|
+
pruned.children = pruned.children
|
|
126
|
+
.filter((c) => ids.has(c?.id))
|
|
127
|
+
.map(prune);
|
|
128
|
+
}
|
|
129
|
+
return pruned;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return prune(root);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractDesignTokens(rootNode) {
|
|
136
|
+
const colors = new Map();
|
|
137
|
+
const fonts = new Map();
|
|
138
|
+
const radiusCounts = new Map();
|
|
139
|
+
|
|
140
|
+
function toSwiftTokenName(styleName) {
|
|
141
|
+
return styleName
|
|
142
|
+
.replace(/[^a-zA-Z0-9/]+/g, ' ')
|
|
143
|
+
.split(/[\s/]+/)
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((w, i) => i === 0 ? w[0].toLowerCase() + w.slice(1) : w[0].toUpperCase() + w.slice(1))
|
|
146
|
+
.join('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
flattenNodes(rootNode, 500).forEach((node) => {
|
|
150
|
+
const style = node.style;
|
|
151
|
+
if (!style) return;
|
|
152
|
+
|
|
153
|
+
if (style.styleName && Array.isArray(style.fills)) {
|
|
154
|
+
const fill = style.fills.find((f) => f && f.visible !== false && f.type === 'SOLID' && f.color);
|
|
155
|
+
if (fill) colors.set(toSwiftTokenName(style.styleName), fill.color);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (node.type === 'TEXT' && style.styleName && node.text?.fontSize) {
|
|
159
|
+
fonts.set(toSwiftTokenName(style.styleName), {
|
|
160
|
+
size: node.text.fontSize,
|
|
161
|
+
weight: node.text.fontName?.style,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof style.cornerRadius === 'number' && style.cornerRadius > 0) {
|
|
166
|
+
radiusCounts.set(style.cornerRadius, (radiusCounts.get(style.cornerRadius) ?? 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
colors: [...colors.entries()].map(([name, color]) => ({ name, color })),
|
|
172
|
+
fonts: [...fonts.entries()].map(([name, font]) => ({ name, ...font })),
|
|
173
|
+
commonRadii: [...radiusCounts.entries()].filter(([, count]) => count >= 3).map(([r]) => r),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildPromptContext(context) {
|
|
178
|
+
const root = getRootNode(context?.metadata);
|
|
179
|
+
if (!root) return null;
|
|
180
|
+
|
|
181
|
+
const rootWidth = root?.geometry?.width ?? 390;
|
|
182
|
+
const prunedRoot = pruneNodeTree(root, 200);
|
|
183
|
+
annotateResponsiveHints(prunedRoot, rootWidth);
|
|
184
|
+
|
|
185
|
+
const designTokens = extractDesignTokens(root);
|
|
186
|
+
const structName = sanitizeName(root.name || 'GeneratedView');
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
structName,
|
|
190
|
+
canvasWidth: rootWidth,
|
|
191
|
+
canvasHeight: root?.geometry?.height ?? 844,
|
|
192
|
+
nodeTree: prunedRoot,
|
|
193
|
+
designTokens,
|
|
194
|
+
reusableComponents: (context?.reusableComponents?.candidates ?? []).slice(0, 10),
|
|
195
|
+
assetRequests: (context?.assetExportPlan?.candidates ?? []).slice(0, 10),
|
|
196
|
+
generationHints: context?.generationHints ?? null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildUserMessage(context, generationMode) {
|
|
201
|
+
const promptCtx = buildPromptContext(context);
|
|
202
|
+
if (!promptCtx) {
|
|
203
|
+
throw new Error('Could not extract root node from design context');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return `Generate SwiftUI code for this Figma design.
|
|
207
|
+
|
|
208
|
+
OUTPUT_STRUCT_NAME: ${promptCtx.structName}
|
|
209
|
+
CANVAS_WIDTH: ${promptCtx.canvasWidth}
|
|
210
|
+
GENERATION_MODE: ${generationMode}
|
|
211
|
+
|
|
212
|
+
DESIGN CONTEXT:
|
|
213
|
+
${JSON.stringify(promptCtx, null, 2)}
|
|
214
|
+
|
|
215
|
+
Output all Swift files using <file name="StructName.swift"> tags.`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Response parser
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
function parseClaudeResponse(text) {
|
|
223
|
+
const fileRegex = /<file name="([^"]+\.swift)">([\s\S]*?)<\/file>/g;
|
|
224
|
+
const files = [];
|
|
225
|
+
let match;
|
|
226
|
+
while ((match = fileRegex.exec(text)) !== null) {
|
|
227
|
+
files.push({ name: match[1], code: match[2].trim() });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (files.length === 0) {
|
|
231
|
+
// Fallback: treat entire response as primary file code
|
|
232
|
+
return { code: text.trim(), designTokensCode: null, componentFiles: [] };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const tokensFile = files.find((f) => f.name === 'DesignTokens.swift');
|
|
236
|
+
const primaryName = buildPromptContext !== null
|
|
237
|
+
? files.find((f) => f.name !== 'DesignTokens.swift' && !f.name.startsWith('Component') && !f.name.endsWith('Row.swift') && !f.name.endsWith('Card.swift') && !f.name.endsWith('Cell.swift'))
|
|
238
|
+
: null;
|
|
239
|
+
const primary = primaryName ?? files.find((f) => f !== tokensFile);
|
|
240
|
+
const componentFiles = files.filter((f) => f !== primary && f !== tokensFile);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
code: primary?.code ?? '',
|
|
244
|
+
designTokensCode: tokensFile?.code ?? null,
|
|
245
|
+
componentFiles,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Anthropic generation
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
async function generateWithAnthropic(context, generationMode, apiKey) {
|
|
254
|
+
let Anthropic;
|
|
255
|
+
try {
|
|
256
|
+
({ Anthropic } = require('@anthropic-ai/sdk'));
|
|
257
|
+
} catch {
|
|
258
|
+
throw new Error('ANTHROPIC_API_KEY is set but @anthropic-ai/sdk is not installed. Run: npm install @anthropic-ai/sdk');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const client = new Anthropic({ apiKey });
|
|
262
|
+
const userMessage = buildUserMessage(context, generationMode);
|
|
263
|
+
|
|
264
|
+
const response = await client.messages.create({
|
|
265
|
+
model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6',
|
|
266
|
+
max_tokens: 8192,
|
|
267
|
+
system: SWIFTUI_SYSTEM_PROMPT,
|
|
268
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const text = response.content
|
|
272
|
+
.filter((b) => b.type === 'text')
|
|
273
|
+
.map((b) => b.text)
|
|
274
|
+
.join('');
|
|
275
|
+
|
|
276
|
+
return parseClaudeResponse(text);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// OpenAI-compatible generation (OpenAI, Ollama, LM Studio, etc.)
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async function generateWithOpenAI(context, generationMode, apiKey) {
|
|
284
|
+
let OpenAI;
|
|
285
|
+
try {
|
|
286
|
+
({ OpenAI } = require('openai'));
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error('OPENAI_API_KEY is set but the openai package is not installed. Run: npm install openai');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const client = new OpenAI({
|
|
292
|
+
apiKey,
|
|
293
|
+
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const model = process.env.OPENAI_MODEL || 'gpt-4o';
|
|
297
|
+
const userMessage = buildUserMessage(context, generationMode);
|
|
298
|
+
|
|
299
|
+
const response = await client.chat.completions.create({
|
|
300
|
+
model,
|
|
301
|
+
max_tokens: 8192,
|
|
302
|
+
messages: [
|
|
303
|
+
{ role: 'system', content: SWIFTUI_SYSTEM_PROMPT },
|
|
304
|
+
{ role: 'user', content: userMessage },
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const text = response.choices[0]?.message?.content ?? '';
|
|
309
|
+
return parseClaudeResponse(text);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Hugging Face Inference API
|
|
314
|
+
//
|
|
315
|
+
// Uses HF's OpenAI-compatible endpoint: https://api-inference.huggingface.co/v1
|
|
316
|
+
//
|
|
317
|
+
// Best models for SwiftUI generation (set HF_MODEL to override):
|
|
318
|
+
// Qwen/Qwen2.5-Coder-32B-Instruct — default, best open-source code model
|
|
319
|
+
// meta-llama/Llama-3.3-70B-Instruct — strong reasoning + code
|
|
320
|
+
// deepseek-ai/DeepSeek-Coder-V2-Instruct — excellent complex code
|
|
321
|
+
// Qwen/Qwen2.5-Coder-7B-Instruct — faster/cheaper for simpler screens
|
|
322
|
+
//
|
|
323
|
+
// Env vars:
|
|
324
|
+
// HF_API_TOKEN — required
|
|
325
|
+
// HF_MODEL — optional, defaults to Qwen2.5-Coder-32B-Instruct
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
const HF_INFERENCE_BASE_URL = 'https://api-inference.huggingface.co/v1';
|
|
329
|
+
const HF_DEFAULT_MODEL = 'Qwen/Qwen2.5-Coder-32B-Instruct';
|
|
330
|
+
|
|
331
|
+
async function generateWithHuggingFace(context, generationMode, apiKey) {
|
|
332
|
+
let OpenAI;
|
|
333
|
+
try {
|
|
334
|
+
({ OpenAI } = require('openai'));
|
|
335
|
+
} catch {
|
|
336
|
+
throw new Error('HF_API_TOKEN is set but the openai package is not installed. Run: npm install openai');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const model = process.env.HF_MODEL || HF_DEFAULT_MODEL;
|
|
340
|
+
const client = new OpenAI({
|
|
341
|
+
apiKey,
|
|
342
|
+
baseURL: HF_INFERENCE_BASE_URL,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const userMessage = buildUserMessage(context, generationMode);
|
|
346
|
+
|
|
347
|
+
const response = await client.chat.completions.create({
|
|
348
|
+
model,
|
|
349
|
+
max_tokens: 8192,
|
|
350
|
+
messages: [
|
|
351
|
+
{ role: 'system', content: SWIFTUI_SYSTEM_PROMPT },
|
|
352
|
+
{ role: 'user', content: userMessage },
|
|
353
|
+
],
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const text = response.choices[0]?.message?.content ?? '';
|
|
357
|
+
return parseClaudeResponse(text);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Public API
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Returns the SwiftUI system prompt and a formatted user message for AI-native
|
|
366
|
+
* generation. AI tools (Claude Code, Cursor, Windsurf, etc.) call this to get a
|
|
367
|
+
* ready-to-use prompt, then generate code with their own model and call
|
|
368
|
+
* write_generated_swiftui_to_xcode — burning zero CreateLex tokens.
|
|
369
|
+
*/
|
|
370
|
+
function buildGenerationPrompt(context) {
|
|
371
|
+
const promptCtx = buildPromptContext(context);
|
|
372
|
+
if (!promptCtx) return null;
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
systemPrompt: SWIFTUI_SYSTEM_PROMPT,
|
|
376
|
+
userMessage: buildUserMessage(context, 'editable'),
|
|
377
|
+
outputStructName: promptCtx.structName,
|
|
378
|
+
assetRequests: promptCtx.assetRequests,
|
|
379
|
+
canvasWidth: promptCtx.canvasWidth,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Generate SwiftUI using the user's own API key (BYOK).
|
|
385
|
+
*
|
|
386
|
+
* Priority order:
|
|
387
|
+
* 1. ANTHROPIC_API_KEY → Claude (best quality, recommended)
|
|
388
|
+
* 2. HF_API_TOKEN → Hugging Face (Qwen2.5-Coder-32B by default — best open-source)
|
|
389
|
+
* 3. OPENAI_API_KEY → OpenAI or any OpenAI-compatible endpoint (Ollama, LM Studio)
|
|
390
|
+
*
|
|
391
|
+
* Returns: { handled, provider, code, designTokensCode, componentFiles, diagnostics }
|
|
392
|
+
*/
|
|
393
|
+
async function generateWithLocalKey(context, generationMode = 'editable') {
|
|
394
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
395
|
+
const hfKey = process.env.HF_API_TOKEN;
|
|
396
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
397
|
+
|
|
398
|
+
if (!anthropicKey && !hfKey && !openaiKey) {
|
|
399
|
+
return null; // No BYOK configured — caller should try next tier
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let parsed;
|
|
403
|
+
let provider;
|
|
404
|
+
|
|
405
|
+
if (anthropicKey) {
|
|
406
|
+
parsed = await generateWithAnthropic(context, generationMode, anthropicKey);
|
|
407
|
+
provider = `local-anthropic:${process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'}`;
|
|
408
|
+
} else if (hfKey) {
|
|
409
|
+
const model = process.env.HF_MODEL || HF_DEFAULT_MODEL;
|
|
410
|
+
parsed = await generateWithHuggingFace(context, generationMode, hfKey);
|
|
411
|
+
provider = `huggingface:${model}`;
|
|
412
|
+
} else {
|
|
413
|
+
parsed = await generateWithOpenAI(context, generationMode, openaiKey);
|
|
414
|
+
const baseUrl = process.env.OPENAI_BASE_URL;
|
|
415
|
+
const model = process.env.OPENAI_MODEL || 'gpt-4o';
|
|
416
|
+
provider = baseUrl ? `local-openai-compatible:${model}` : `local-openai:${model}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
handled: true,
|
|
421
|
+
provider,
|
|
422
|
+
screenType: 'llm-generated',
|
|
423
|
+
code: parsed.code,
|
|
424
|
+
designTokensCode: parsed.designTokensCode,
|
|
425
|
+
componentFiles: parsed.componentFiles,
|
|
426
|
+
assetRequests: [],
|
|
427
|
+
diagnostics: [],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = {
|
|
432
|
+
generateWithLocalKey,
|
|
433
|
+
buildGenerationPrompt,
|
|
434
|
+
parseClaudeResponse,
|
|
435
|
+
buildPromptContext,
|
|
436
|
+
SWIFTUI_SYSTEM_PROMPT,
|
|
437
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const {
|
|
10
|
+
AUTH_FILE,
|
|
11
|
+
ensureApiBaseUrl,
|
|
12
|
+
saveAuth,
|
|
13
|
+
validateTokenFormat,
|
|
14
|
+
} = require('./createlex-auth.cjs');
|
|
15
|
+
|
|
16
|
+
function getLoginBaseUrl() {
|
|
17
|
+
const explicit = process.env.CREATELEX_LOGIN_BASE_URL?.trim();
|
|
18
|
+
if (explicit) {
|
|
19
|
+
return explicit.replace(/\/+$/, '');
|
|
20
|
+
}
|
|
21
|
+
return ensureApiBaseUrl(process.env.CREATELEX_API_BASE_URL);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function openBrowser(url) {
|
|
25
|
+
const platform = process.platform;
|
|
26
|
+
if (platform === 'darwin') {
|
|
27
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'win32') {
|
|
31
|
+
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sendHtml(res, statusCode, title, body) {
|
|
38
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
39
|
+
res.end(`<!DOCTYPE html>
|
|
40
|
+
<html>
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8" />
|
|
43
|
+
<title>${title}</title>
|
|
44
|
+
<style>
|
|
45
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e2e8f0; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; }
|
|
46
|
+
.card { max-width: 560px; padding: 32px; border-radius: 16px; background: #111827; box-shadow: 0 10px 40px rgba(0,0,0,0.35); text-align: center; }
|
|
47
|
+
h1 { margin-top: 0; font-size: 28px; }
|
|
48
|
+
p { line-height: 1.5; color: #cbd5e1; }
|
|
49
|
+
code { background: #1f2937; padding: 2px 6px; border-radius: 6px; }
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<div class="card">
|
|
54
|
+
<h1>${title}</h1>
|
|
55
|
+
${body}
|
|
56
|
+
</div>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const args = new Set(process.argv.slice(2));
|
|
63
|
+
if (args.has('--help') || args.has('-h')) {
|
|
64
|
+
console.log(`CreateLex figma-swiftui login
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
npx @createlex/figma-swiftui-mcp login
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--no-open Print the CreateLex login URL instead of opening a browser
|
|
71
|
+
--help Show this help message
|
|
72
|
+
`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const noOpen = args.has('--no-open');
|
|
77
|
+
const state = randomBytes(16).toString('hex');
|
|
78
|
+
const apiBaseUrl = ensureApiBaseUrl(process.env.CREATELEX_API_BASE_URL);
|
|
79
|
+
const loginBaseUrl = getLoginBaseUrl();
|
|
80
|
+
|
|
81
|
+
let server;
|
|
82
|
+
const completion = new Promise((resolve, reject) => {
|
|
83
|
+
const timeout = setTimeout(() => {
|
|
84
|
+
reject(new Error('Timed out waiting for CreateLex login callback.'));
|
|
85
|
+
}, 5 * 60 * 1000);
|
|
86
|
+
|
|
87
|
+
server = http.createServer((req, res) => {
|
|
88
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
89
|
+
if (url.pathname !== '/callback') {
|
|
90
|
+
sendHtml(res, 404, 'Not Found', '<p>This callback path is reserved for the CreateLex figma-swiftui login flow.</p>');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const token = url.searchParams.get('token') || '';
|
|
95
|
+
const refreshToken = url.searchParams.get('refresh_token') || '';
|
|
96
|
+
const userId = url.searchParams.get('user_id') || '';
|
|
97
|
+
const email = url.searchParams.get('email') || '';
|
|
98
|
+
const hasSubscription = url.searchParams.get('has_subscription') === 'true';
|
|
99
|
+
const returnedState = url.searchParams.get('state') || '';
|
|
100
|
+
const error = url.searchParams.get('error') || '';
|
|
101
|
+
|
|
102
|
+
if (returnedState !== state) {
|
|
103
|
+
sendHtml(res, 400, 'Login Failed', '<p>The login callback state did not match. Close this window and run <code>npx @createlex/figma-swiftui-mcp login</code> again.</p>');
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
reject(new Error('Login callback state mismatch.'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error) {
|
|
110
|
+
sendHtml(res, 400, 'Login Failed', `<p>${error}</p><p>Close this window and run <code>npx @createlex/figma-swiftui-mcp login</code> again.</p>`);
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
reject(new Error(error));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!hasSubscription) {
|
|
117
|
+
sendHtml(res, 403, 'Subscription Required', '<p>Your CreateLex account does not have an active subscription for figma-swiftui.</p>');
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
reject(new Error('Active CreateLex subscription required for figma-swiftui.'));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const validation = validateTokenFormat(token);
|
|
124
|
+
if (!validation.valid) {
|
|
125
|
+
sendHtml(res, 400, 'Login Failed', '<p>The returned CreateLex token was invalid.</p>');
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
reject(new Error(`Returned token invalid: ${validation.reason}`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
saveAuth({
|
|
132
|
+
token,
|
|
133
|
+
refreshToken,
|
|
134
|
+
refresh_token: refreshToken,
|
|
135
|
+
userId,
|
|
136
|
+
email,
|
|
137
|
+
apiBaseUrl,
|
|
138
|
+
savedAt: new Date().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
sendHtml(
|
|
142
|
+
res,
|
|
143
|
+
200,
|
|
144
|
+
'Login Successful',
|
|
145
|
+
`<p>You are now signed in to CreateLex for <code>figma-swiftui</code>.</p>
|
|
146
|
+
<p>Auth file written to <code>${AUTH_FILE}</code>.</p>
|
|
147
|
+
<p>You can close this window and return to Terminal.</p>`
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
resolve({ userId, email });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server.listen(0, '127.0.0.1', () => {
|
|
155
|
+
const address = server.address();
|
|
156
|
+
if (!address || typeof address === 'string') {
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
reject(new Error('Failed to determine local login callback port.'));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const callbackUrl = `http://127.0.0.1:${address.port}/callback`;
|
|
163
|
+
const loginUrl = `${loginBaseUrl}/mcp/figma-swiftui/login?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
|
|
164
|
+
|
|
165
|
+
console.log(noOpen ? 'CreateLex login URL:' : 'Opening CreateLex login in your browser...');
|
|
166
|
+
console.log(`If the browser does not open, visit:\n${loginUrl}\n`);
|
|
167
|
+
if (!noOpen) {
|
|
168
|
+
try {
|
|
169
|
+
openBrowser(loginUrl);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn(`Could not open browser automatically: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const result = await completion;
|
|
179
|
+
console.log(`CreateLex login saved for ${result.email || result.userId || 'unknown-user'}.`);
|
|
180
|
+
console.log(`Auth file: ${AUTH_FILE}`);
|
|
181
|
+
} finally {
|
|
182
|
+
await new Promise((resolve) => server?.close(() => resolve()));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch((error) => {
|
|
187
|
+
console.error(`CreateLex login failed: ${error.message}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
});
|