@createlex/figma-swiftui-mcp 1.0.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.
@@ -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://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
+ };
@@ -0,0 +1,190 @@
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 getWebBaseUrl() {
17
+ const explicit = process.env.CREATELEX_WEB_BASE_URL?.trim();
18
+ if (explicit) {
19
+ return explicit.replace(/\/+$/, '');
20
+ }
21
+ const apiBase = ensureApiBaseUrl(process.env.CREATELEX_API_BASE_URL);
22
+ return apiBase.replace(/\/api$/, '');
23
+ }
24
+
25
+ function openBrowser(url) {
26
+ const platform = process.platform;
27
+ if (platform === 'darwin') {
28
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
29
+ return;
30
+ }
31
+ if (platform === 'win32') {
32
+ spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
33
+ return;
34
+ }
35
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
36
+ }
37
+
38
+ function sendHtml(res, statusCode, title, body) {
39
+ res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
40
+ res.end(`<!DOCTYPE html>
41
+ <html>
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <title>${title}</title>
45
+ <style>
46
+ 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; }
47
+ .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; }
48
+ h1 { margin-top: 0; font-size: 28px; }
49
+ p { line-height: 1.5; color: #cbd5e1; }
50
+ code { background: #1f2937; padding: 2px 6px; border-radius: 6px; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div class="card">
55
+ <h1>${title}</h1>
56
+ ${body}
57
+ </div>
58
+ </body>
59
+ </html>`);
60
+ }
61
+
62
+ async function main() {
63
+ const args = new Set(process.argv.slice(2));
64
+ if (args.has('--help') || args.has('-h')) {
65
+ console.log(`CreateLex figma-swiftui login
66
+
67
+ Usage:
68
+ npx @createlex/figma-swiftui-mcp login
69
+
70
+ Options:
71
+ --no-open Print the CreateLex login URL instead of opening a browser
72
+ --help Show this help message
73
+ `);
74
+ return;
75
+ }
76
+
77
+ const noOpen = args.has('--no-open');
78
+ const state = randomBytes(16).toString('hex');
79
+ const apiBaseUrl = ensureApiBaseUrl(process.env.CREATELEX_API_BASE_URL);
80
+ const webBaseUrl = getWebBaseUrl();
81
+
82
+ let server;
83
+ const completion = new Promise((resolve, reject) => {
84
+ const timeout = setTimeout(() => {
85
+ reject(new Error('Timed out waiting for CreateLex login callback.'));
86
+ }, 5 * 60 * 1000);
87
+
88
+ server = http.createServer((req, res) => {
89
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
90
+ if (url.pathname !== '/callback') {
91
+ sendHtml(res, 404, 'Not Found', '<p>This callback path is reserved for the CreateLex figma-swiftui login flow.</p>');
92
+ return;
93
+ }
94
+
95
+ const token = url.searchParams.get('token') || '';
96
+ const refreshToken = url.searchParams.get('refresh_token') || '';
97
+ const userId = url.searchParams.get('user_id') || '';
98
+ const email = url.searchParams.get('email') || '';
99
+ const hasSubscription = url.searchParams.get('has_subscription') === 'true';
100
+ const returnedState = url.searchParams.get('state') || '';
101
+ const error = url.searchParams.get('error') || '';
102
+
103
+ if (returnedState !== state) {
104
+ 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>');
105
+ clearTimeout(timeout);
106
+ reject(new Error('Login callback state mismatch.'));
107
+ return;
108
+ }
109
+
110
+ if (error) {
111
+ sendHtml(res, 400, 'Login Failed', `<p>${error}</p><p>Close this window and run <code>npx @createlex/figma-swiftui-mcp login</code> again.</p>`);
112
+ clearTimeout(timeout);
113
+ reject(new Error(error));
114
+ return;
115
+ }
116
+
117
+ if (!hasSubscription) {
118
+ sendHtml(res, 403, 'Subscription Required', '<p>Your CreateLex account does not have an active subscription for figma-swiftui.</p>');
119
+ clearTimeout(timeout);
120
+ reject(new Error('Active CreateLex subscription required for figma-swiftui.'));
121
+ return;
122
+ }
123
+
124
+ const validation = validateTokenFormat(token);
125
+ if (!validation.valid) {
126
+ sendHtml(res, 400, 'Login Failed', '<p>The returned CreateLex token was invalid.</p>');
127
+ clearTimeout(timeout);
128
+ reject(new Error(`Returned token invalid: ${validation.reason}`));
129
+ return;
130
+ }
131
+
132
+ saveAuth({
133
+ token,
134
+ refreshToken,
135
+ refresh_token: refreshToken,
136
+ userId,
137
+ email,
138
+ apiBaseUrl,
139
+ savedAt: new Date().toISOString(),
140
+ });
141
+
142
+ sendHtml(
143
+ res,
144
+ 200,
145
+ 'Login Successful',
146
+ `<p>You are now signed in to CreateLex for <code>figma-swiftui</code>.</p>
147
+ <p>Auth file written to <code>${AUTH_FILE}</code>.</p>
148
+ <p>You can close this window and return to Terminal.</p>`
149
+ );
150
+
151
+ clearTimeout(timeout);
152
+ resolve({ userId, email });
153
+ });
154
+
155
+ server.listen(0, '127.0.0.1', () => {
156
+ const address = server.address();
157
+ if (!address || typeof address === 'string') {
158
+ clearTimeout(timeout);
159
+ reject(new Error('Failed to determine local login callback port.'));
160
+ return;
161
+ }
162
+
163
+ const callbackUrl = `http://127.0.0.1:${address.port}/callback`;
164
+ const loginUrl = `${webBaseUrl}/api/mcp/figma-swiftui/login?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
165
+
166
+ console.log(noOpen ? 'CreateLex login URL:' : 'Opening CreateLex login in your browser...');
167
+ console.log(`If the browser does not open, visit:\n${loginUrl}\n`);
168
+ if (!noOpen) {
169
+ try {
170
+ openBrowser(loginUrl);
171
+ } catch (error) {
172
+ console.warn(`Could not open browser automatically: ${error.message}`);
173
+ }
174
+ }
175
+ });
176
+ });
177
+
178
+ try {
179
+ const result = await completion;
180
+ console.log(`CreateLex login saved for ${result.email || result.userId || 'unknown-user'}.`);
181
+ console.log(`Auth file: ${AUTH_FILE}`);
182
+ } finally {
183
+ await new Promise((resolve) => server?.close(() => resolve()));
184
+ }
185
+ }
186
+
187
+ main().catch((error) => {
188
+ console.error(`CreateLex login failed: ${error.message}`);
189
+ process.exit(1);
190
+ });