@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,364 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = process.env.CREATELEX_CONFIG_DIR || path.join(os.homedir(), '.createlex');
|
|
7
|
+
const AUTH_FILE = process.env.CREATELEX_AUTH_FILE || path.join(CONFIG_DIR, 'auth.json');
|
|
8
|
+
|
|
9
|
+
function normalizeWebBase(value) {
|
|
10
|
+
if (!value || typeof value !== 'string') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
let normalized = value.trim();
|
|
14
|
+
if (!normalized) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
18
|
+
normalized = `https://${normalized}`;
|
|
19
|
+
}
|
|
20
|
+
return normalized.replace(/\/+$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureApiBaseUrl(value) {
|
|
24
|
+
let base = normalizeWebBase(value)
|
|
25
|
+
|| normalizeWebBase(process.env.CREATELEX_API_BASE_URL)
|
|
26
|
+
|| normalizeWebBase(process.env.API_BASE_URL)
|
|
27
|
+
|| 'https://api.createlex.com';
|
|
28
|
+
|
|
29
|
+
if (!base.toLowerCase().endsWith('/api')) {
|
|
30
|
+
base = `${base}/api`;
|
|
31
|
+
}
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shouldBypassAuth() {
|
|
36
|
+
return process.env.FIGMA_SWIFTUI_BYPASS_AUTH === 'true' || process.env.BYPASS_SUBSCRIPTION === 'true';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadAuth() {
|
|
40
|
+
if (!fs.existsSync(AUTH_FILE)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function saveAuth(auth) {
|
|
52
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
53
|
+
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decodeJwtPayload(token) {
|
|
57
|
+
const parts = token.split('.');
|
|
58
|
+
if (parts.length !== 3 || parts.some((part) => !part)) {
|
|
59
|
+
throw new Error('invalid_token_structure');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
63
|
+
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
|
|
64
|
+
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateTokenFormat(token) {
|
|
68
|
+
if (!token) {
|
|
69
|
+
return { valid: false, reason: 'missing_token' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parts = token.split('.');
|
|
73
|
+
if (parts.length !== 3) {
|
|
74
|
+
return { valid: false, reason: 'invalid_token_format' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (parts.some((part) => !part)) {
|
|
78
|
+
return { valid: false, reason: 'invalid_token_structure' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const payload = decodeJwtPayload(token);
|
|
83
|
+
if (payload.exp) {
|
|
84
|
+
const expiryTime = payload.exp * 1000;
|
|
85
|
+
const clockSkewBuffer = 30 * 1000;
|
|
86
|
+
if (Date.now() >= expiryTime + clockSkewBuffer) {
|
|
87
|
+
return { valid: false, reason: 'token_expired' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { valid: true, payload };
|
|
91
|
+
} catch {
|
|
92
|
+
return { valid: false, reason: 'invalid_token_structure' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function refreshSession(refreshToken, apiBaseUrl) {
|
|
97
|
+
if (!refreshToken) {
|
|
98
|
+
throw new Error('missing_refresh_token');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const response = await fetch(`${ensureApiBaseUrl(apiBaseUrl)}/mcp/figma-swiftui/session/refresh`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
refreshToken,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const payload = await response.json().catch(() => ({}));
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new Error(payload.error_description || payload.error || `refresh_failed:${response.status}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return payload;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function ensureAccessToken(explicitToken, apiBaseUrl) {
|
|
120
|
+
const rawToken = explicitToken || process.env.FIGMA_SWIFTUI_ACCESS_TOKEN || process.env.CREATELEX_ACCESS_TOKEN;
|
|
121
|
+
if (rawToken) {
|
|
122
|
+
const validation = validateTokenFormat(rawToken);
|
|
123
|
+
if (!validation.valid) {
|
|
124
|
+
throw new Error(`CreateLex token is invalid: ${validation.reason}`);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
token: rawToken,
|
|
128
|
+
payload: validation.payload,
|
|
129
|
+
source: 'env',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const auth = loadAuth();
|
|
134
|
+
if (!auth?.token) {
|
|
135
|
+
throw new Error(`CreateLex login required. Run "npx @createlex/figma-swiftui-mcp login" to create ${AUTH_FILE}.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const currentValidation = validateTokenFormat(auth.token);
|
|
139
|
+
if (currentValidation.valid) {
|
|
140
|
+
return {
|
|
141
|
+
token: auth.token,
|
|
142
|
+
payload: currentValidation.payload,
|
|
143
|
+
source: AUTH_FILE,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const refreshToken = auth.refreshToken || auth.refresh_token;
|
|
148
|
+
if (currentValidation.reason !== 'token_expired' || !refreshToken) {
|
|
149
|
+
throw new Error(`CreateLex token is invalid: ${currentValidation.reason}. Run "npx @createlex/figma-swiftui-mcp login" again.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const refreshed = await refreshSession(refreshToken, apiBaseUrl);
|
|
153
|
+
const nextToken = refreshed.access_token;
|
|
154
|
+
const nextValidation = validateTokenFormat(nextToken);
|
|
155
|
+
if (!nextValidation.valid) {
|
|
156
|
+
throw new Error(`CreateLex token refresh failed: ${nextValidation.reason}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
saveAuth({
|
|
160
|
+
...auth,
|
|
161
|
+
token: nextToken,
|
|
162
|
+
refreshToken: refreshed.refresh_token || refreshToken,
|
|
163
|
+
refresh_token: refreshed.refresh_token || refreshToken,
|
|
164
|
+
email: refreshed.user?.email || auth.email || nextValidation.payload?.email || null,
|
|
165
|
+
userId: refreshed.user?.id || auth.userId || nextValidation.payload?.sub || null,
|
|
166
|
+
savedAt: new Date().toISOString(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
token: nextToken,
|
|
171
|
+
payload: nextValidation.payload,
|
|
172
|
+
source: AUTH_FILE,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function generateDeviceId() {
|
|
177
|
+
const cpus = os.cpus();
|
|
178
|
+
const networkInterfaces = os.networkInterfaces();
|
|
179
|
+
const hardwareString = JSON.stringify({
|
|
180
|
+
hostname: os.hostname(),
|
|
181
|
+
platform: os.platform(),
|
|
182
|
+
arch: os.arch(),
|
|
183
|
+
cpuModel: cpus[0]?.model || 'unknown',
|
|
184
|
+
cpuCount: cpus.length,
|
|
185
|
+
macAddresses: Object.values(networkInterfaces)
|
|
186
|
+
.flat()
|
|
187
|
+
.filter((iface) => iface && !iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00')
|
|
188
|
+
.map((iface) => iface.mac)
|
|
189
|
+
.sort(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return crypto.createHash('sha256').update(hardwareString).digest('hex');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getDeviceInfo() {
|
|
196
|
+
return {
|
|
197
|
+
deviceId: generateDeviceId(),
|
|
198
|
+
deviceName: os.hostname(),
|
|
199
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
200
|
+
osVersion: os.release(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function postJson(url, options) {
|
|
205
|
+
const response = await fetch(url, options);
|
|
206
|
+
const data = await response.json().catch(() => ({}));
|
|
207
|
+
return { response, data };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function postAuthorizedApi(session, apiPath, body) {
|
|
211
|
+
const apiBaseUrl = ensureApiBaseUrl(session?.apiBaseUrl);
|
|
212
|
+
const access = await ensureAccessToken(undefined, apiBaseUrl);
|
|
213
|
+
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
|
|
214
|
+
return postJson(`${apiBaseUrl}${normalizedPath}`, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: {
|
|
217
|
+
Authorization: `Bearer ${access.token}`,
|
|
218
|
+
'Content-Type': 'application/json',
|
|
219
|
+
},
|
|
220
|
+
body: JSON.stringify(body ?? {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function validateStartupWithFallback({ apiBaseUrl, accessToken, body }) {
|
|
225
|
+
const productUrl = `${apiBaseUrl}/mcp/figma-swiftui/session/validate`;
|
|
226
|
+
const fallbackUrl = `${apiBaseUrl}/mcp/validate-startup`;
|
|
227
|
+
const headers = {
|
|
228
|
+
Authorization: `Bearer ${accessToken}`,
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const productAttempt = await postJson(productUrl, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers,
|
|
235
|
+
body: JSON.stringify(body),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (productAttempt.response.ok || productAttempt.response.status !== 404) {
|
|
239
|
+
return {
|
|
240
|
+
url: productUrl,
|
|
241
|
+
...productAttempt,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const fallbackAttempt = await postJson(fallbackUrl, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers,
|
|
248
|
+
body: JSON.stringify(body),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
url: fallbackUrl,
|
|
253
|
+
...fallbackAttempt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function authorizeRuntimeStartup(options = {}) {
|
|
258
|
+
if (shouldBypassAuth()) {
|
|
259
|
+
return {
|
|
260
|
+
authorized: true,
|
|
261
|
+
bypass: true,
|
|
262
|
+
apiBaseUrl: ensureApiBaseUrl(options.apiBaseUrl),
|
|
263
|
+
validatedAt: new Date().toISOString(),
|
|
264
|
+
reason: 'bypass',
|
|
265
|
+
userId: null,
|
|
266
|
+
email: null,
|
|
267
|
+
tokenSource: 'bypass',
|
|
268
|
+
mcpToken: null,
|
|
269
|
+
mcpPayload: null,
|
|
270
|
+
expiresAt: null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const apiBaseUrl = ensureApiBaseUrl(options.apiBaseUrl);
|
|
275
|
+
const access = await ensureAccessToken(options.accessToken, apiBaseUrl);
|
|
276
|
+
const deviceInfo = getDeviceInfo();
|
|
277
|
+
const startupPayload = {
|
|
278
|
+
deviceId: deviceInfo.deviceId,
|
|
279
|
+
deviceInfo,
|
|
280
|
+
clientName: 'figma-swiftui-mcp',
|
|
281
|
+
clientVersion: '1.0.0',
|
|
282
|
+
requestedTtlSeconds: 86400,
|
|
283
|
+
};
|
|
284
|
+
const { response, data, url } = await validateStartupWithFallback({
|
|
285
|
+
apiBaseUrl,
|
|
286
|
+
accessToken: access.token,
|
|
287
|
+
body: startupPayload,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok || !data.valid) {
|
|
291
|
+
const reason = data?.error || `CreateLex backend rejected MCP startup (${response.status})`;
|
|
292
|
+
throw new Error(reason);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
authorized: true,
|
|
297
|
+
bypass: false,
|
|
298
|
+
apiBaseUrl,
|
|
299
|
+
validatedAt: new Date().toISOString(),
|
|
300
|
+
userId: access.payload?.sub || null,
|
|
301
|
+
email: access.payload?.email || null,
|
|
302
|
+
tokenSource: access.source,
|
|
303
|
+
mcpToken: data.mcpToken,
|
|
304
|
+
mcpPayload: data.mcpPayload,
|
|
305
|
+
expiresAt: typeof data.expiresIn === 'number' ? new Date(Date.now() + (data.expiresIn * 1000)).toISOString() : null,
|
|
306
|
+
startupEndpoint: url,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function validateRuntimeSession(session) {
|
|
311
|
+
if (!session || session.bypass) {
|
|
312
|
+
return {
|
|
313
|
+
valid: true,
|
|
314
|
+
session,
|
|
315
|
+
bypass: true,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { response, data } = await postJson(`${session.apiBaseUrl}/mcp/validate-token`, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {
|
|
322
|
+
'Content-Type': 'application/json',
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
token: session.mcpToken,
|
|
326
|
+
payload: session.mcpPayload,
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (response.ok && data.valid) {
|
|
331
|
+
return {
|
|
332
|
+
valid: true,
|
|
333
|
+
session: {
|
|
334
|
+
...session,
|
|
335
|
+
validatedAt: new Date().toISOString(),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const refreshed = await authorizeRuntimeStartup({ apiBaseUrl: session.apiBaseUrl });
|
|
342
|
+
return {
|
|
343
|
+
valid: true,
|
|
344
|
+
refreshed: true,
|
|
345
|
+
session: refreshed,
|
|
346
|
+
};
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return {
|
|
349
|
+
valid: false,
|
|
350
|
+
error: error instanceof Error ? error.message : 'CreateLex MCP session validation failed',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
AUTH_FILE,
|
|
357
|
+
authorizeRuntimeStartup,
|
|
358
|
+
ensureApiBaseUrl,
|
|
359
|
+
postAuthorizedApi,
|
|
360
|
+
saveAuth,
|
|
361
|
+
shouldBypassAuth,
|
|
362
|
+
validateTokenFormat,
|
|
363
|
+
validateRuntimeSession,
|
|
364
|
+
};
|