desen-cli 1.0.3-draft

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,5 @@
1
+
2
+ 
3
+ > desen-cli@1.0.3-draft build /Users/selmanay/Desktop/desen/packages/cli
4
+ > tsc
5
+
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const commander_1 = require("commander");
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const desen_core_1 = require("desen-core");
12
+ const express_1 = __importDefault(require("express"));
13
+ const cors_1 = __importDefault(require("cors"));
14
+ const program = new commander_1.Command();
15
+ program
16
+ .name("desen")
17
+ .description("DESEN Protocol CLI - Validation & Audit Tool")
18
+ .version("1.0.0-rc.1");
19
+ // Helper to find json files recursively
20
+ function getJsonFiles(dir, fileList = []) {
21
+ if (fs_1.default.statSync(dir).isFile() && dir.endsWith('.json')) {
22
+ fileList.push(dir);
23
+ return fileList;
24
+ }
25
+ if (fs_1.default.statSync(dir).isDirectory()) {
26
+ const files = fs_1.default.readdirSync(dir);
27
+ for (const file of files) {
28
+ const fullPath = path_1.default.join(dir, file);
29
+ if (fs_1.default.statSync(fullPath).isDirectory()) {
30
+ getJsonFiles(fullPath, fileList);
31
+ }
32
+ else if (fullPath.endsWith('.json')) {
33
+ fileList.push(fullPath);
34
+ }
35
+ }
36
+ }
37
+ return fileList;
38
+ }
39
+ // 1. VALIDATE COMMAND (CI/CD Firewall)
40
+ program
41
+ .command("validate")
42
+ .description("Validate JSON files against DESEN SurfaceSpec")
43
+ .argument("<path>", "Directory or file path to validate")
44
+ .action((targetPath) => {
45
+ console.log(chalk_1.default.cyan(`\nšŸ” DESEN Validation Started for: ${targetPath}`));
46
+ let hasFailures = false;
47
+ let totalFiles = 0;
48
+ try {
49
+ const targetFullPath = path_1.default.resolve(process.cwd(), targetPath);
50
+ if (!fs_1.default.existsSync(targetFullPath)) {
51
+ console.error(chalk_1.default.red(`Path does not exist: ${targetFullPath}`));
52
+ process.exit(1);
53
+ }
54
+ const files = getJsonFiles(targetFullPath);
55
+ totalFiles = files.length;
56
+ files.forEach((file) => {
57
+ try {
58
+ const content = fs_1.default.readFileSync(file, "utf-8");
59
+ const jsonData = JSON.parse(content);
60
+ const result = desen_core_1.surfaceSpec.safeParse(jsonData);
61
+ if (result.success) {
62
+ console.log(chalk_1.default.green(` [PASS] ${path_1.default.basename(file)}`));
63
+ }
64
+ else {
65
+ hasFailures = true;
66
+ console.log(chalk_1.default.red(` [FAIL] ${path_1.default.basename(file)}`));
67
+ result.error.errors.forEach((err) => {
68
+ console.log(chalk_1.default.yellow(` -> Path [${err.path.join(".")}] : ${err.message}`));
69
+ });
70
+ }
71
+ }
72
+ catch (e) {
73
+ hasFailures = true;
74
+ console.log(chalk_1.default.red(` [ERROR] ${path_1.default.basename(file)} - Invalid JSON: ${e.message}`));
75
+ }
76
+ });
77
+ }
78
+ catch (error) {
79
+ console.error(chalk_1.default.red(`Validation process failed: ${error.message}`));
80
+ process.exit(1);
81
+ }
82
+ console.log("\n---");
83
+ if (hasFailures) {
84
+ console.log(chalk_1.default.red.bold(`āŒ Validation failed! Some DESEN files are invalid. Pipeline rejected.`));
85
+ process.exit(1); // Pipeline Break
86
+ }
87
+ else {
88
+ console.log(chalk_1.default.green.bold(`āœ… All ${totalFiles} DESEN files passed successfully!`));
89
+ process.exit(0); // Success
90
+ }
91
+ });
92
+ // 2. AUDIT COMMAND (Governance & Risk Analysis)
93
+ program
94
+ .command("audit")
95
+ .description("Perform Risk Analysis on DESEN JSON files (R1-R3 Levels)")
96
+ .argument("<path>", "Directory or file to audit")
97
+ .action((targetPath) => {
98
+ console.log(chalk_1.default.cyan(`\nšŸ›” DESEN Governance Audit Started for: ${targetPath}`));
99
+ try {
100
+ const targetFullPath = path_1.default.resolve(process.cwd(), targetPath);
101
+ if (!fs_1.default.existsSync(targetFullPath)) {
102
+ console.error(chalk_1.default.red(`Path does not exist: ${targetFullPath}`));
103
+ process.exit(1);
104
+ }
105
+ const files = getJsonFiles(targetFullPath);
106
+ let auditLogCount = 0;
107
+ files.forEach((file) => {
108
+ try {
109
+ const content = fs_1.default.readFileSync(file, "utf-8");
110
+ const jsonData = JSON.parse(content);
111
+ let hasWarningsForFile = false;
112
+ const messages = [];
113
+ const stringified = JSON.stringify(jsonData);
114
+ // Basic Risk Detections according to SPEC.md section 5
115
+ if (!jsonData.telemetry || !jsonData.telemetry.emit) {
116
+ hasWarningsForFile = true;
117
+ messages.push(chalk_1.default.yellow(` [R2 RISK] Missing root telemetry. Surfaces must emit observability constraints.`));
118
+ }
119
+ if (stringified.includes('"kind":"mutate"')) {
120
+ hasWarningsForFile = true;
121
+ messages.push(chalk_1.default.magenta(` [R3 RISK] Action 'mutate' detected. Requires Canary Rollout (A-Mode constraints).`));
122
+ }
123
+ if (stringified.includes('x_')) {
124
+ hasWarningsForFile = true;
125
+ messages.push(chalk_1.default.blue(` [INFO] Vendor extensions detected (x_ properties). Skipping strict semantic match.`));
126
+ }
127
+ if (hasWarningsForFile) {
128
+ console.log(chalk_1.default.white(`šŸ“ ${path_1.default.basename(file)}`));
129
+ messages.forEach(msg => console.log(msg));
130
+ auditLogCount++;
131
+ }
132
+ }
133
+ catch (e) {
134
+ console.log(chalk_1.default.red(` Skipping ${path_1.default.basename(file)} (Unparseable)`));
135
+ }
136
+ });
137
+ console.log("\n---");
138
+ console.log(chalk_1.default.cyan.bold(`šŸ“Š Audit complete. Files with risk items found: ${auditLogCount}/${files.length}`));
139
+ // Only logs, DOES NOT exit 1 for audit warnings.
140
+ process.exit(0);
141
+ }
142
+ catch (error) {
143
+ console.error(chalk_1.default.red(`Audit process failed: ${error.message}`));
144
+ process.exit(1);
145
+ }
146
+ });
147
+ // 3. DAEMON COMMAND (Local Sync Server)
148
+ program
149
+ .command("daemon")
150
+ .description("Start the local sync server for Figma (localhost:3000)")
151
+ .option("-p, --port <number>", "Port to run the server on", "3000")
152
+ .action((options) => {
153
+ const port = parseInt(options.port, 10) || 3000;
154
+ const app = (0, express_1.default)();
155
+ app.use((0, cors_1.default)({ origin: '*' }));
156
+ app.use(express_1.default.json({ limit: "10mb" }));
157
+ app.get("/", (req, res) => {
158
+ const html = `
159
+ <!DOCTYPE html>
160
+ <html>
161
+ <head>
162
+ <title>DESEN Protocol Daemon</title>
163
+ <style>
164
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; padding: 60px 20px; background: #0a0a0a; color: #fff; text-align: center; }
165
+ .card { background: #111; border: 1px solid #333; padding: 40px; border-radius: 12px; max-width: 600px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
166
+ h1 { color: #fff; margin-top: 0; font-weight: 600; font-size: 24px; }
167
+ p { color: #a1a1aa; line-height: 1.6; font-size: 15px; margin-bottom: 24px; }
168
+ .code { background: #000; border: 1px solid #222; padding: 16px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; color: #4ade80; font-size: 14px; text-align: left; overflow-x: auto; }
169
+ </style>
170
+ </head>
171
+ <body>
172
+ <div class="card">
173
+ <h1>šŸš€ DESEN Daemon Active</h1>
174
+ <p>The Synchronization (Sync) server is waiting for incoming <strong>UI AST</strong> data from Figma. It can safely continue running in the background.</p>
175
+ <p>To preview your design live as a standalone application using the <strong>desen</strong> render engine, open a new terminal in your project directory and run the following commands:</p>
176
+ <div class="code">cd desen-workspace<br/>npm install<br/>npm run dev</div>
177
+ </div>
178
+ </body>
179
+ </html>
180
+ `;
181
+ res.send(html);
182
+ });
183
+ app.post("/sync", (req, res) => {
184
+ try {
185
+ // Support both legacy {path, ast} and new { payloads: [{path, ast}] }
186
+ const payloads = req.body.payloads || [{ path: req.body.path, ast: req.body.ast }];
187
+ if (!payloads || payloads.length === 0 || !payloads[0].path) {
188
+ return res.status(400).json({ error: "Missing payloads or path in request body" });
189
+ }
190
+ // SECURITY BARRICADE: Validate strictly using @desen/core specs before disk IO
191
+ for (const item of payloads) {
192
+ if (!item.path || !item.ast)
193
+ continue;
194
+ const result = desen_core_1.surfaceSpec.safeParse(item.ast);
195
+ if (!result.success) {
196
+ console.error(chalk_1.default.red(`āŒ [Fail-Closed] Payload validation failed for ${item.path}`));
197
+ return res.status(400).json({
198
+ error: "Validation failed against desen-core schemas",
199
+ path: item.path,
200
+ issues: result.error.errors
201
+ });
202
+ }
203
+ }
204
+ const projectRoot = process.cwd();
205
+ const workspaceDir = path_1.default.join(projectRoot, "desen-workspace");
206
+ let savedCount = 0;
207
+ for (const item of payloads) {
208
+ if (!item.path || !item.ast)
209
+ continue;
210
+ const fullPath = path_1.default.join(workspaceDir, item.path);
211
+ const dirName = path_1.default.dirname(fullPath);
212
+ if (!fs_1.default.existsSync(dirName)) {
213
+ fs_1.default.mkdirSync(dirName, { recursive: true });
214
+ }
215
+ fs_1.default.writeFileSync(fullPath, JSON.stringify(item.ast, null, 2), "utf-8");
216
+ console.log(chalk_1.default.green(`āœ… Synced: ${item.path}`));
217
+ savedCount++;
218
+ }
219
+ // -------------------------------------------------------------
220
+ // SCAFFOLDING LAUNCHER
221
+ // If this is the first sync (package.json doesn't exist),
222
+ // setup a standalone Next.js template for desen
223
+ // -------------------------------------------------------------
224
+ const pkgPath = path_1.default.join(workspaceDir, "package.json");
225
+ if (!fs_1.default.existsSync(pkgPath)) {
226
+ console.log(chalk_1.default.blue(`\nšŸ› ļø No package.json found. Scaffolding standalone DESEN app...`));
227
+ // 1. package.json
228
+ fs_1.default.writeFileSync(pkgPath, JSON.stringify({
229
+ name: "desen-workspace-app",
230
+ version: "0.1.0",
231
+ private: true,
232
+ scripts: {
233
+ "dev": "next dev -p 3001",
234
+ "build": "next build",
235
+ "start": "next start"
236
+ },
237
+ dependencies: {
238
+ "desen-core": "latest",
239
+ "desen": "latest",
240
+ "next": "14.2.3",
241
+ "react": "18.3.1",
242
+ "react-dom": "18.3.1"
243
+ },
244
+ devDependencies: {
245
+ "@types/node": "^20",
246
+ "@types/react": "^18",
247
+ "@types/react-dom": "^18",
248
+ "typescript": "^5"
249
+ }
250
+ }, null, 2));
251
+ // 2. tsconfig.json
252
+ fs_1.default.writeFileSync(path_1.default.join(workspaceDir, "tsconfig.json"), JSON.stringify({
253
+ "compilerOptions": {
254
+ "target": "es5",
255
+ "lib": ["dom", "dom.iterable", "esnext"],
256
+ "allowJs": true,
257
+ "skipLibCheck": true,
258
+ "strict": true,
259
+ "noEmit": true,
260
+ "esModuleInterop": true,
261
+ "module": "esnext",
262
+ "moduleResolution": "bundler",
263
+ "resolveJsonModule": true,
264
+ "isolatedModules": true,
265
+ "jsx": "preserve",
266
+ "incremental": true,
267
+ "plugins": [{ "name": "next" }],
268
+ "paths": { "@/*": ["./src/*"] }
269
+ },
270
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
271
+ "exclude": ["node_modules"]
272
+ }, null, 2));
273
+ // 3. App structure
274
+ const appDir = path_1.default.join(workspaceDir, "src", "app");
275
+ fs_1.default.mkdirSync(appDir, { recursive: true });
276
+ // Find first surface directly
277
+ const firstSurface = payloads.find((p) => p.path.startsWith('surface/'))?.path || 'surface/frame-1.desen.json';
278
+ // API Route to load local .desen.json dynamically
279
+ const apiDir = path_1.default.join(appDir, "api", "surface", "route");
280
+ fs_1.default.mkdirSync(apiDir, { recursive: true });
281
+ fs_1.default.writeFileSync(path_1.default.join(apiDir, "route.ts"), `
282
+ import { NextResponse } from 'next/server';
283
+ import { promises as fs } from 'fs';
284
+ import path from 'path';
285
+
286
+ async function resolveRefs(obj: any, baseDir: string): Promise<any> {
287
+ if (!obj || typeof obj !== 'object') return obj;
288
+ if (Array.isArray(obj)) {
289
+ return Promise.all(obj.map(item => resolveRefs(item, baseDir)));
290
+ }
291
+ if (obj.$ref && typeof obj.$ref === 'string') {
292
+ try {
293
+ const refPath = path.join(baseDir, obj.$ref);
294
+ const content = await fs.readFile(refPath, 'utf-8');
295
+ const resolved = JSON.parse(content);
296
+ // Recursively resolve inside the loaded ref
297
+ return await resolveRefs(resolved, baseDir);
298
+ } catch (e) {
299
+ console.error("Failed to resolve $ref:", obj.$ref, e);
300
+ return obj; // Return original if failed
301
+ }
302
+ }
303
+
304
+ // Resolve all keys if object
305
+ const resolvedObj: any = {};
306
+ for (const [key, value] of Object.entries(obj)) {
307
+ resolvedObj[key] = await resolveRefs(value, baseDir);
308
+ }
309
+ return resolvedObj;
310
+ }
311
+
312
+ export async function GET() {
313
+ try {
314
+ const baseDir = process.cwd();
315
+ const filePath = path.join(baseDir, '${firstSurface}');
316
+ const content = await fs.readFile(filePath, 'utf-8');
317
+ let parsed = JSON.parse(content);
318
+
319
+ parsed = await resolveRefs(parsed, baseDir);
320
+
321
+ return NextResponse.json(parsed);
322
+ } catch (e) {
323
+ return NextResponse.json({ error: 'System is waiting for your sync!' }, { status: 404 });
324
+ }
325
+ }
326
+ `.trim());
327
+ // layout.tsx — with Google Fonts for Figma font support
328
+ fs_1.default.writeFileSync(path_1.default.join(appDir, "layout.tsx"), `
329
+ import './globals.css';
330
+
331
+ export const metadata = { title: "DESEN Workspace" };
332
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
333
+ return (
334
+ <html lang="en">
335
+ <head>
336
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
337
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
338
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
339
+ </head>
340
+ <body>
341
+ {children}
342
+ </body>
343
+ </html>
344
+ );
345
+ }
346
+ `.trim());
347
+ // globals.css — proper reset
348
+ fs_1.default.writeFileSync(path_1.default.join(appDir, "globals.css"), `
349
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
350
+ html, body {
351
+ width: 100%; min-height: 100vh; margin: 0; padding: 0;
352
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
353
+ -webkit-font-smoothing: antialiased;
354
+ -moz-osx-font-smoothing: grayscale;
355
+ background: #ffffff;
356
+ }
357
+ `.trim());
358
+ // page.tsx — renders the full surface (not data.root)
359
+ fs_1.default.writeFileSync(path_1.default.join(appDir, "page.tsx"), `
360
+ "use client";
361
+ import React, { useEffect, useState } from "react";
362
+ import { DesenProvider, DesenRenderer } from "desen";
363
+ import { registry } from "../lib/desenRegistry";
364
+
365
+ export default function Page() {
366
+ const [data, setData] = useState<any>(null);
367
+
368
+ useEffect(() => {
369
+ const fetchUI = () => {
370
+ fetch("/api/surface/route")
371
+ .then(res => res.json())
372
+ .then(json => { if(!json.error) setData(json) })
373
+ .catch(e => console.error(e));
374
+ };
375
+ fetchUI();
376
+ const intv = setInterval(fetchUI, 2000);
377
+ return () => clearInterval(intv);
378
+ }, []);
379
+
380
+ if (!data) return <div style={{padding: 40, fontFamily: 'monospace'}}>Waiting for Figma Sync...</div>;
381
+
382
+ // SPEC.md: Surface = full page. Pass full surface object, not data.root.
383
+ return (
384
+ <DesenProvider
385
+ registry={registry}
386
+ session_id="workspace_session"
387
+ revision_id="rev_1"
388
+ onTelemetry={e => console.log("[Telemetry]", e)}
389
+ onAction={a => console.log("[Action]", a)}
390
+ >
391
+ <DesenRenderer node={data} />
392
+ </DesenProvider>
393
+ );
394
+ }
395
+ `.trim());
396
+ // Registry
397
+ const libDir = path_1.default.join(workspaceDir, "src", "lib");
398
+ fs_1.default.mkdirSync(libDir, { recursive: true });
399
+ fs_1.default.writeFileSync(path_1.default.join(libDir, "desenRegistry.tsx"), `
400
+ import React from 'react';
401
+
402
+ function resolveToken(tokenStr: string): string | undefined {
403
+ if (!tokenStr) return undefined;
404
+ const match = tokenStr.match(/t_([a-fA-F0-9]{3,8})/);
405
+ return match ? \`#\${match[1]}\` : undefined;
406
+ }
407
+
408
+ function resolveColor(tokenStr?: string, hexStr?: string): string | undefined {
409
+ if (tokenStr) return resolveToken(tokenStr);
410
+ if (hexStr) return hexStr;
411
+ return undefined;
412
+ }
413
+
414
+ function buildCssFromNode(layout: any, style: any, opts?: { isText?: boolean; isSurfaceRoot?: boolean }): React.CSSProperties {
415
+ const css: any = { boxSizing: 'border-box' };
416
+
417
+ if (layout) {
418
+ css.display = 'flex';
419
+ css.flexDirection = layout.direction === 'row' ? 'row' : 'column';
420
+ if (layout.gap !== undefined && layout.gap !== 0) css.gap = layout.gap;
421
+
422
+ const am: Record<string, string> = { start:'flex-start', end:'flex-end', center:'center', 'space-between':'space-between', 'space-around':'space-around', 'space-evenly':'space-evenly' };
423
+ css.alignItems = am[layout.align_items] || 'stretch';
424
+ css.justifyContent = am[layout.align_content] || 'flex-start';
425
+
426
+ if (layout.padding !== undefined) {
427
+ css.padding = Array.isArray(layout.padding) ? layout.padding.map((p:number)=>p+'px').join(' ') : layout.padding !== 0 ? layout.padding+'px' : undefined;
428
+ }
429
+
430
+ if (opts?.isSurfaceRoot) {
431
+ css.width = '100%'; css.minHeight = '100vh';
432
+ } else {
433
+ if (layout.sizing_h === 'FILL') css.width = '100%';
434
+ else if (layout.sizing_h === 'FIXED' && style?.width !== undefined) css.width = style.width;
435
+ if (layout.sizing_v === 'FILL') css.height = '100%';
436
+ else if (layout.sizing_v === 'FIXED' && style?.height !== undefined) css.height = style.height;
437
+ }
438
+ }
439
+
440
+ if (style) {
441
+ const bgColor = resolveColor(style.bg_color_token, style.bg_color);
442
+ if (bgColor) { if (opts?.isText) css.color = bgColor; else css.backgroundColor = bgColor; }
443
+ const bc = resolveColor(style.border_color_token, style.border_color);
444
+ if (bc) css.borderColor = bc;
445
+ if (style.border_width !== undefined) { css.borderWidth = style.border_width; css.borderStyle = 'solid'; }
446
+ if (style.border_radius !== undefined) {
447
+ css.borderRadius = Array.isArray(style.border_radius) ? style.border_radius.map((r:number)=>r+'px').join(' ') : style.border_radius;
448
+ }
449
+ if (!layout && !opts?.isText) { if (style.width !== undefined) css.width = style.width; if (style.height !== undefined) css.height = style.height; }
450
+ if (style.opacity !== undefined) css.opacity = style.opacity;
451
+ if (style.font_size) css.fontSize = style.font_size;
452
+ if (style.font_weight) css.fontWeight = style.font_weight;
453
+ if (style.font_family) css.fontFamily = style.font_family;
454
+ if (style.letter_spacing) css.letterSpacing = style.letter_spacing;
455
+ if (style.line_height) css.lineHeight = style.line_height + 'px';
456
+ if (style.text_align) css.textAlign = style.text_align;
457
+ if (style.text_decoration) css.textDecoration = style.text_decoration;
458
+ if (style.text_transform) css.textTransform = style.text_transform;
459
+ }
460
+ return css;
461
+ }
462
+
463
+ const DynamicFontLoader = ({ fonts }: { fonts?: string[] }) => {
464
+ if (!fonts || fonts.length === 0) return null;
465
+
466
+ // Aggregate weights per family for efficient Google Fonts URL
467
+ const familyMap: Record<string, Set<number>> = {};
468
+ fonts.forEach(f => {
469
+ const [family, weight] = f.split(':');
470
+ if (!familyMap[family]) familyMap[family] = new Set();
471
+ familyMap[family].add(parseInt(weight) || 400);
472
+ });
473
+
474
+ return (
475
+ <>
476
+ {Object.entries(familyMap).map(([family, weights]) => {
477
+ const weightStr = Array.from(weights).sort().join(';');
478
+ const url = \`https://fonts.googleapis.com/css2?family=\${family.replace(/ /g, '+')}:wght@\${weightStr}&display=swap\`;
479
+ return <link key={url} rel="stylesheet" href={url} />;
480
+ })}
481
+ </>
482
+ );
483
+ };
484
+
485
+ const SurfaceComponent = (props: any) => (
486
+ <div style={{ width: '100%', minHeight: '100vh', boxSizing: 'border-box' }}>
487
+ <DynamicFontLoader fonts={props.metadata?.fonts} />
488
+ {props.children}
489
+ </div>
490
+ );
491
+
492
+ const GenericContainer = (props: any) => (
493
+ <div style={buildCssFromNode(props.layout, props.style, { isSurfaceRoot: props._isSurfaceRoot })}>{props.children}</div>
494
+ );
495
+
496
+ const TextComponent = (props: any) => (
497
+ <span style={buildCssFromNode(props.layout, props.style, { isText: true })}>{props.content || props.text || props.name}</span>
498
+ );
499
+
500
+ const ButtonComponent = (props: any) => {
501
+ const adjLayout = { ...props.layout };
502
+ if (adjLayout && !props.children && adjLayout.align_content === 'space-between') adjLayout.align_content = 'center';
503
+ const css: any = { ...buildCssFromNode(adjLayout, props.style), cursor: 'pointer', border: 'none' };
504
+ if (props.text_style) {
505
+ const tc = resolveColor(props.text_style.bg_color_token, props.text_style.bg_color);
506
+ if (tc) css.color = tc;
507
+ if (props.text_style.font_size) css.fontSize = props.text_style.font_size;
508
+ if (props.text_style.font_weight) css.fontWeight = props.text_style.font_weight;
509
+ if (props.text_style.font_family) css.fontFamily = props.text_style.font_family;
510
+ if (props.text_style.text_align) css.textAlign = props.text_style.text_align;
511
+ }
512
+ return <button style={css}>{props.children || props.content || props.text || props.name}</button>;
513
+ };
514
+
515
+ export const registry: Record<string, React.FC<any>> = {
516
+ surface: SurfaceComponent,
517
+ composition: GenericContainer,
518
+ frame: GenericContainer,
519
+ element: GenericContainer,
520
+ group: GenericContainer,
521
+ stack: GenericContainer,
522
+ text: TextComponent,
523
+ Text: TextComponent,
524
+ button: ButtonComponent,
525
+ input: (props: any) => <input style={buildCssFromNode(props.layout, props.style)} placeholder={props.content || props.name} />,
526
+ icon: (props: any) => <div style={{ ...buildCssFromNode(props.layout, props.style), display:'inline-flex', alignItems:'center', justifyContent:'center' }}>āš™ļø</div>,
527
+ };
528
+ `.trim());
529
+ console.log(chalk_1.default.green(`āœ… Done! Standalone App Scaffolded.`));
530
+ // 5. Add to pnpm-workspace.yaml if needed
531
+ const pnpmYamlPath = path_1.default.join(projectRoot, "pnpm-workspace.yaml");
532
+ if (fs_1.default.existsSync(pnpmYamlPath)) {
533
+ let yamlContent = fs_1.default.readFileSync(pnpmYamlPath, 'utf-8');
534
+ if (!yamlContent.includes("desen-workspace")) {
535
+ yamlContent += `\n - "desen-workspace"\n`;
536
+ fs_1.default.writeFileSync(pnpmYamlPath, yamlContent);
537
+ console.log(chalk_1.default.blue(`šŸ› ļø Added 'desen-workspace' to pnpm - workspace.yaml`));
538
+ }
539
+ }
540
+ } // End if(!package.json)
541
+ res.status(200).json({ success: true, message: `Successfully saved ${savedCount} files.` });
542
+ }
543
+ catch (error) {
544
+ console.error(chalk_1.default.red("āŒ Sync Error:"), error);
545
+ res.status(500).json({ error: error.message });
546
+ }
547
+ });
548
+ app.listen(port, () => {
549
+ console.log(chalk_1.default.cyan(`\nšŸš€ DESEN Sync Daemon running on http://localhost:${port}`));
550
+ console.log(chalk_1.default.gray(` Waiting for Figma plugin 'Sync to Localhost' events...`));
551
+ });
552
+ }); // End setup action
553
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "desen-cli",
3
+ "version": "1.0.3-draft",
4
+ "main": "dist/index.js",
5
+ "bin": {
6
+ "desen": "./dist/index.js"
7
+ },
8
+ "dependencies": {
9
+ "chalk": "^5.6.2",
10
+ "commander": "^14.0.3",
11
+ "express": "^4.19.2",
12
+ "cors": "^2.8.5",
13
+ "desen-core": "1.0.3-draft"
14
+ },
15
+ "devDependencies": {
16
+ "@types/cors": "^2.8.19",
17
+ "@types/express": "^5.0.6",
18
+ "@types/node": "^20.19.33",
19
+ "typescript": "^5.0.0"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "dev": "tsc -w",
24
+ "clean": "rm -rf dist"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { surfaceSpec } from "desen-core";
8
+ import express from "express";
9
+ import cors from "cors";
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("desen")
15
+ .description("DESEN Protocol CLI - Validation & Audit Tool")
16
+ .version("1.0.0-rc.1");
17
+
18
+ // Helper to find json files recursively
19
+ function getJsonFiles(dir: string, fileList: string[] = []): string[] {
20
+ if (fs.statSync(dir).isFile() && dir.endsWith('.json')) {
21
+ fileList.push(dir);
22
+ return fileList;
23
+ }
24
+
25
+ if (fs.statSync(dir).isDirectory()) {
26
+ const files = fs.readdirSync(dir);
27
+ for (const file of files) {
28
+ const fullPath = path.join(dir, file);
29
+ if (fs.statSync(fullPath).isDirectory()) {
30
+ getJsonFiles(fullPath, fileList);
31
+ } else if (fullPath.endsWith('.json')) {
32
+ fileList.push(fullPath);
33
+ }
34
+ }
35
+ }
36
+ return fileList;
37
+ }
38
+
39
+ // 1. VALIDATE COMMAND (CI/CD Firewall)
40
+ program
41
+ .command("validate")
42
+ .description("Validate JSON files against DESEN SurfaceSpec")
43
+ .argument("<path>", "Directory or file path to validate")
44
+ .action((targetPath) => {
45
+ console.log(chalk.cyan(`\nšŸ” DESEN Validation Started for: ${targetPath}`));
46
+
47
+ let hasFailures = false;
48
+ let totalFiles = 0;
49
+
50
+ try {
51
+ const targetFullPath = path.resolve(process.cwd(), targetPath);
52
+ if (!fs.existsSync(targetFullPath)) {
53
+ console.error(chalk.red(`Path does not exist: ${targetFullPath}`));
54
+ process.exit(1);
55
+ }
56
+
57
+ const files = getJsonFiles(targetFullPath);
58
+ totalFiles = files.length;
59
+
60
+ files.forEach((file) => {
61
+ try {
62
+ const content = fs.readFileSync(file, "utf-8");
63
+ const jsonData = JSON.parse(content);
64
+
65
+ const result = surfaceSpec.safeParse(jsonData);
66
+
67
+ if (result.success) {
68
+ console.log(chalk.green(` [PASS] ${path.basename(file)}`));
69
+ } else {
70
+ hasFailures = true;
71
+ console.log(chalk.red(` [FAIL] ${path.basename(file)}`));
72
+
73
+ result.error.errors.forEach((err) => {
74
+ console.log(chalk.yellow(` -> Path [${err.path.join(".")}] : ${err.message}`));
75
+ });
76
+ }
77
+ } catch (e: any) {
78
+ hasFailures = true;
79
+ console.log(chalk.red(` [ERROR] ${path.basename(file)} - Invalid JSON: ${e.message}`));
80
+ }
81
+ });
82
+
83
+ } catch (error: any) {
84
+ console.error(chalk.red(`Validation process failed: ${error.message}`));
85
+ process.exit(1);
86
+ }
87
+
88
+ console.log("\n---");
89
+ if (hasFailures) {
90
+ console.log(chalk.red.bold(`āŒ Validation failed! Some DESEN files are invalid. Pipeline rejected.`));
91
+ process.exit(1); // Pipeline Break
92
+ } else {
93
+ console.log(chalk.green.bold(`āœ… All ${totalFiles} DESEN files passed successfully!`));
94
+ process.exit(0); // Success
95
+ }
96
+ });
97
+
98
+ // 2. AUDIT COMMAND (Governance & Risk Analysis)
99
+ program
100
+ .command("audit")
101
+ .description("Perform Risk Analysis on DESEN JSON files (R1-R3 Levels)")
102
+ .argument("<path>", "Directory or file to audit")
103
+ .action((targetPath) => {
104
+ console.log(chalk.cyan(`\nšŸ›” DESEN Governance Audit Started for: ${targetPath}`));
105
+
106
+ try {
107
+ const targetFullPath = path.resolve(process.cwd(), targetPath);
108
+ if (!fs.existsSync(targetFullPath)) {
109
+ console.error(chalk.red(`Path does not exist: ${targetFullPath}`));
110
+ process.exit(1);
111
+ }
112
+
113
+ const files = getJsonFiles(targetFullPath);
114
+ let auditLogCount = 0;
115
+
116
+ files.forEach((file) => {
117
+ try {
118
+ const content = fs.readFileSync(file, "utf-8");
119
+ const jsonData = JSON.parse(content);
120
+
121
+ let hasWarningsForFile = false;
122
+ const messages: string[] = [];
123
+
124
+ const stringified = JSON.stringify(jsonData);
125
+
126
+ // Basic Risk Detections according to SPEC.md section 5
127
+ if (!jsonData.telemetry || !jsonData.telemetry.emit) {
128
+ hasWarningsForFile = true;
129
+ messages.push(chalk.yellow(` [R2 RISK] Missing root telemetry. Surfaces must emit observability constraints.`));
130
+ }
131
+
132
+ if (stringified.includes('"kind":"mutate"')) {
133
+ hasWarningsForFile = true;
134
+ messages.push(chalk.magenta(` [R3 RISK] Action 'mutate' detected. Requires Canary Rollout (A-Mode constraints).`));
135
+ }
136
+
137
+ if (stringified.includes('x_')) {
138
+ hasWarningsForFile = true;
139
+ messages.push(chalk.blue(` [INFO] Vendor extensions detected (x_ properties). Skipping strict semantic match.`));
140
+ }
141
+
142
+ if (hasWarningsForFile) {
143
+ console.log(chalk.white(`šŸ“ ${path.basename(file)}`));
144
+ messages.forEach(msg => console.log(msg));
145
+ auditLogCount++;
146
+ }
147
+
148
+ } catch (e) {
149
+ console.log(chalk.red(` Skipping ${path.basename(file)} (Unparseable)`));
150
+ }
151
+ });
152
+
153
+ console.log("\n---");
154
+ console.log(chalk.cyan.bold(`šŸ“Š Audit complete. Files with risk items found: ${auditLogCount}/${files.length}`));
155
+ // Only logs, DOES NOT exit 1 for audit warnings.
156
+ process.exit(0);
157
+
158
+ } catch (error: any) {
159
+ console.error(chalk.red(`Audit process failed: ${error.message}`));
160
+ process.exit(1);
161
+ }
162
+ });
163
+
164
+ // 3. DAEMON COMMAND (Local Sync Server)
165
+ program
166
+ .command("daemon")
167
+ .description("Start the local sync server for Figma (localhost:3000)")
168
+ .option("-p, --port <number>", "Port to run the server on", "3000")
169
+ .action((options) => {
170
+ const port = parseInt(options.port, 10) || 3000;
171
+ const app = express();
172
+
173
+ app.use(cors({ origin: '*' }));
174
+ app.use(express.json({ limit: "10mb" }));
175
+ app.get("/", (req, res) => {
176
+ const html = `
177
+ <!DOCTYPE html>
178
+ <html>
179
+ <head>
180
+ <title>DESEN Protocol Daemon</title>
181
+ <style>
182
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; padding: 60px 20px; background: #0a0a0a; color: #fff; text-align: center; }
183
+ .card { background: #111; border: 1px solid #333; padding: 40px; border-radius: 12px; max-width: 600px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
184
+ h1 { color: #fff; margin-top: 0; font-weight: 600; font-size: 24px; }
185
+ p { color: #a1a1aa; line-height: 1.6; font-size: 15px; margin-bottom: 24px; }
186
+ .code { background: #000; border: 1px solid #222; padding: 16px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; color: #4ade80; font-size: 14px; text-align: left; overflow-x: auto; }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <div class="card">
191
+ <h1>šŸš€ DESEN Daemon Active</h1>
192
+ <p>The Synchronization (Sync) server is waiting for incoming <strong>UI AST</strong> data from Figma. It can safely continue running in the background.</p>
193
+ <p>To preview your design live as a standalone application using the <strong>desen</strong> render engine, open a new terminal in your project directory and run the following commands:</p>
194
+ <div class="code">cd desen-workspace<br/>npm install<br/>npm run dev</div>
195
+ </div>
196
+ </body>
197
+ </html>
198
+ `;
199
+ res.send(html);
200
+ });
201
+
202
+ app.post("/sync", (req, res) => {
203
+ try {
204
+ // Support both legacy {path, ast} and new { payloads: [{path, ast}] }
205
+ const payloads = req.body.payloads || [{ path: req.body.path, ast: req.body.ast }];
206
+
207
+ if (!payloads || payloads.length === 0 || !payloads[0].path) {
208
+ return res.status(400).json({ error: "Missing payloads or path in request body" });
209
+ }
210
+
211
+ // SECURITY BARRICADE: Validate strictly using @desen/core specs before disk IO
212
+ for (const item of payloads) {
213
+ if (!item.path || !item.ast) continue;
214
+
215
+ const result = surfaceSpec.safeParse(item.ast);
216
+ if (!result.success) {
217
+ console.error(chalk.red(`āŒ [Fail-Closed] Payload validation failed for ${item.path}`));
218
+ return res.status(400).json({
219
+ error: "Validation failed against desen-core schemas",
220
+ path: item.path,
221
+ issues: result.error.errors
222
+ });
223
+ }
224
+ }
225
+
226
+ const projectRoot = process.cwd();
227
+ const workspaceDir = path.join(projectRoot, "desen-workspace");
228
+
229
+ let savedCount = 0;
230
+
231
+ for (const item of payloads) {
232
+ if (!item.path || !item.ast) continue;
233
+
234
+ const fullPath = path.join(workspaceDir, item.path);
235
+ const dirName = path.dirname(fullPath);
236
+
237
+ if (!fs.existsSync(dirName)) {
238
+ fs.mkdirSync(dirName, { recursive: true });
239
+ }
240
+
241
+ fs.writeFileSync(fullPath, JSON.stringify(item.ast, null, 2), "utf-8");
242
+ console.log(chalk.green(`āœ… Synced: ${item.path}`));
243
+ savedCount++;
244
+ }
245
+
246
+ // -------------------------------------------------------------
247
+ // SCAFFOLDING LAUNCHER
248
+ // If this is the first sync (package.json doesn't exist),
249
+ // setup a standalone Next.js template for desen
250
+ // -------------------------------------------------------------
251
+ const pkgPath = path.join(workspaceDir, "package.json");
252
+ if (!fs.existsSync(pkgPath)) {
253
+ console.log(chalk.blue(`\nšŸ› ļø No package.json found. Scaffolding standalone DESEN app...`));
254
+
255
+ // 1. package.json
256
+ fs.writeFileSync(pkgPath, JSON.stringify({
257
+ name: "desen-workspace-app",
258
+ version: "0.1.0",
259
+ private: true,
260
+ scripts: {
261
+ "dev": "next dev -p 3001",
262
+ "build": "next build",
263
+ "start": "next start"
264
+ },
265
+ dependencies: {
266
+ "desen-core": "latest",
267
+ "desen": "latest",
268
+ "next": "14.2.3",
269
+ "react": "18.3.1",
270
+ "react-dom": "18.3.1"
271
+ },
272
+ devDependencies: {
273
+ "@types/node": "^20",
274
+ "@types/react": "^18",
275
+ "@types/react-dom": "^18",
276
+ "typescript": "^5"
277
+ }
278
+ }, null, 2));
279
+
280
+ // 2. tsconfig.json
281
+ fs.writeFileSync(path.join(workspaceDir, "tsconfig.json"), JSON.stringify({
282
+ "compilerOptions": {
283
+ "target": "es5",
284
+ "lib": ["dom", "dom.iterable", "esnext"],
285
+ "allowJs": true,
286
+ "skipLibCheck": true,
287
+ "strict": true,
288
+ "noEmit": true,
289
+ "esModuleInterop": true,
290
+ "module": "esnext",
291
+ "moduleResolution": "bundler",
292
+ "resolveJsonModule": true,
293
+ "isolatedModules": true,
294
+ "jsx": "preserve",
295
+ "incremental": true,
296
+ "plugins": [{ "name": "next" }],
297
+ "paths": { "@/*": ["./src/*"] }
298
+ },
299
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
300
+ "exclude": ["node_modules"]
301
+ }, null, 2));
302
+
303
+ // 3. App structure
304
+ const appDir = path.join(workspaceDir, "src", "app");
305
+ fs.mkdirSync(appDir, { recursive: true });
306
+
307
+ // Find first surface directly
308
+ const firstSurface = payloads.find((p: any) => p.path.startsWith('surface/'))?.path || 'surface/frame-1.desen.json';
309
+
310
+ // API Route to load local .desen.json dynamically
311
+ const apiDir = path.join(appDir, "api", "surface", "route");
312
+ fs.mkdirSync(apiDir, { recursive: true });
313
+ fs.writeFileSync(path.join(apiDir, "route.ts"), `
314
+ import { NextResponse } from 'next/server';
315
+ import { promises as fs } from 'fs';
316
+ import path from 'path';
317
+
318
+ async function resolveRefs(obj: any, baseDir: string): Promise<any> {
319
+ if (!obj || typeof obj !== 'object') return obj;
320
+ if (Array.isArray(obj)) {
321
+ return Promise.all(obj.map(item => resolveRefs(item, baseDir)));
322
+ }
323
+ if (obj.$ref && typeof obj.$ref === 'string') {
324
+ try {
325
+ const refPath = path.join(baseDir, obj.$ref);
326
+ const content = await fs.readFile(refPath, 'utf-8');
327
+ const resolved = JSON.parse(content);
328
+ // Recursively resolve inside the loaded ref
329
+ return await resolveRefs(resolved, baseDir);
330
+ } catch (e) {
331
+ console.error("Failed to resolve $ref:", obj.$ref, e);
332
+ return obj; // Return original if failed
333
+ }
334
+ }
335
+
336
+ // Resolve all keys if object
337
+ const resolvedObj: any = {};
338
+ for (const [key, value] of Object.entries(obj)) {
339
+ resolvedObj[key] = await resolveRefs(value, baseDir);
340
+ }
341
+ return resolvedObj;
342
+ }
343
+
344
+ export async function GET() {
345
+ try {
346
+ const baseDir = process.cwd();
347
+ const filePath = path.join(baseDir, '${firstSurface}');
348
+ const content = await fs.readFile(filePath, 'utf-8');
349
+ let parsed = JSON.parse(content);
350
+
351
+ parsed = await resolveRefs(parsed, baseDir);
352
+
353
+ return NextResponse.json(parsed);
354
+ } catch (e) {
355
+ return NextResponse.json({ error: 'System is waiting for your sync!' }, { status: 404 });
356
+ }
357
+ }
358
+ `.trim());
359
+
360
+ // layout.tsx — with Google Fonts for Figma font support
361
+ fs.writeFileSync(path.join(appDir, "layout.tsx"), `
362
+ import './globals.css';
363
+
364
+ export const metadata = { title: "DESEN Workspace" };
365
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
366
+ return (
367
+ <html lang="en">
368
+ <head>
369
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
370
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
371
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
372
+ </head>
373
+ <body>
374
+ {children}
375
+ </body>
376
+ </html>
377
+ );
378
+ }
379
+ `.trim());
380
+
381
+ // globals.css — proper reset
382
+ fs.writeFileSync(path.join(appDir, "globals.css"), `
383
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
384
+ html, body {
385
+ width: 100%; min-height: 100vh; margin: 0; padding: 0;
386
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
387
+ -webkit-font-smoothing: antialiased;
388
+ -moz-osx-font-smoothing: grayscale;
389
+ background: #ffffff;
390
+ }
391
+ `.trim());
392
+
393
+ // page.tsx — renders the full surface (not data.root)
394
+ fs.writeFileSync(path.join(appDir, "page.tsx"), `
395
+ "use client";
396
+ import React, { useEffect, useState } from "react";
397
+ import { DesenProvider, DesenRenderer } from "desen";
398
+ import { registry } from "../lib/desenRegistry";
399
+
400
+ export default function Page() {
401
+ const [data, setData] = useState<any>(null);
402
+
403
+ useEffect(() => {
404
+ const fetchUI = () => {
405
+ fetch("/api/surface/route")
406
+ .then(res => res.json())
407
+ .then(json => { if(!json.error) setData(json) })
408
+ .catch(e => console.error(e));
409
+ };
410
+ fetchUI();
411
+ const intv = setInterval(fetchUI, 2000);
412
+ return () => clearInterval(intv);
413
+ }, []);
414
+
415
+ if (!data) return <div style={{padding: 40, fontFamily: 'monospace'}}>Waiting for Figma Sync...</div>;
416
+
417
+ // SPEC.md: Surface = full page. Pass full surface object, not data.root.
418
+ return (
419
+ <DesenProvider
420
+ registry={registry}
421
+ session_id="workspace_session"
422
+ revision_id="rev_1"
423
+ onTelemetry={e => console.log("[Telemetry]", e)}
424
+ onAction={a => console.log("[Action]", a)}
425
+ >
426
+ <DesenRenderer node={data} />
427
+ </DesenProvider>
428
+ );
429
+ }
430
+ `.trim());
431
+
432
+ // Registry
433
+ const libDir = path.join(workspaceDir, "src", "lib");
434
+ fs.mkdirSync(libDir, { recursive: true });
435
+
436
+ fs.writeFileSync(path.join(libDir, "desenRegistry.tsx"), `
437
+ import React from 'react';
438
+
439
+ function resolveToken(tokenStr: string): string | undefined {
440
+ if (!tokenStr) return undefined;
441
+ const match = tokenStr.match(/t_([a-fA-F0-9]{3,8})/);
442
+ return match ? \`#\${match[1]}\` : undefined;
443
+ }
444
+
445
+ function resolveColor(tokenStr?: string, hexStr?: string): string | undefined {
446
+ if (tokenStr) return resolveToken(tokenStr);
447
+ if (hexStr) return hexStr;
448
+ return undefined;
449
+ }
450
+
451
+ function buildCssFromNode(layout: any, style: any, opts?: { isText?: boolean; isSurfaceRoot?: boolean }): React.CSSProperties {
452
+ const css: any = { boxSizing: 'border-box' };
453
+
454
+ if (layout) {
455
+ css.display = 'flex';
456
+ css.flexDirection = layout.direction === 'row' ? 'row' : 'column';
457
+ if (layout.gap !== undefined && layout.gap !== 0) css.gap = layout.gap;
458
+
459
+ const am: Record<string, string> = { start:'flex-start', end:'flex-end', center:'center', 'space-between':'space-between', 'space-around':'space-around', 'space-evenly':'space-evenly' };
460
+ css.alignItems = am[layout.align_items] || 'stretch';
461
+ css.justifyContent = am[layout.align_content] || 'flex-start';
462
+
463
+ if (layout.padding !== undefined) {
464
+ css.padding = Array.isArray(layout.padding) ? layout.padding.map((p:number)=>p+'px').join(' ') : layout.padding !== 0 ? layout.padding+'px' : undefined;
465
+ }
466
+
467
+ if (opts?.isSurfaceRoot) {
468
+ css.width = '100%'; css.minHeight = '100vh';
469
+ } else {
470
+ if (layout.sizing_h === 'FILL') css.width = '100%';
471
+ else if (layout.sizing_h === 'FIXED' && style?.width !== undefined) css.width = style.width;
472
+ if (layout.sizing_v === 'FILL') css.height = '100%';
473
+ else if (layout.sizing_v === 'FIXED' && style?.height !== undefined) css.height = style.height;
474
+ }
475
+ }
476
+
477
+ if (style) {
478
+ const bgColor = resolveColor(style.bg_color_token, style.bg_color);
479
+ if (bgColor) { if (opts?.isText) css.color = bgColor; else css.backgroundColor = bgColor; }
480
+ const bc = resolveColor(style.border_color_token, style.border_color);
481
+ if (bc) css.borderColor = bc;
482
+ if (style.border_width !== undefined) { css.borderWidth = style.border_width; css.borderStyle = 'solid'; }
483
+ if (style.border_radius !== undefined) {
484
+ css.borderRadius = Array.isArray(style.border_radius) ? style.border_radius.map((r:number)=>r+'px').join(' ') : style.border_radius;
485
+ }
486
+ if (!layout && !opts?.isText) { if (style.width !== undefined) css.width = style.width; if (style.height !== undefined) css.height = style.height; }
487
+ if (style.opacity !== undefined) css.opacity = style.opacity;
488
+ if (style.font_size) css.fontSize = style.font_size;
489
+ if (style.font_weight) css.fontWeight = style.font_weight;
490
+ if (style.font_family) css.fontFamily = style.font_family;
491
+ if (style.letter_spacing) css.letterSpacing = style.letter_spacing;
492
+ if (style.line_height) css.lineHeight = style.line_height + 'px';
493
+ if (style.text_align) css.textAlign = style.text_align;
494
+ if (style.text_decoration) css.textDecoration = style.text_decoration;
495
+ if (style.text_transform) css.textTransform = style.text_transform;
496
+ }
497
+ return css;
498
+ }
499
+
500
+ const DynamicFontLoader = ({ fonts }: { fonts?: string[] }) => {
501
+ if (!fonts || fonts.length === 0) return null;
502
+
503
+ // Aggregate weights per family for efficient Google Fonts URL
504
+ const familyMap: Record<string, Set<number>> = {};
505
+ fonts.forEach(f => {
506
+ const [family, weight] = f.split(':');
507
+ if (!familyMap[family]) familyMap[family] = new Set();
508
+ familyMap[family].add(parseInt(weight) || 400);
509
+ });
510
+
511
+ return (
512
+ <>
513
+ {Object.entries(familyMap).map(([family, weights]) => {
514
+ const weightStr = Array.from(weights).sort().join(';');
515
+ const url = \`https://fonts.googleapis.com/css2?family=\${family.replace(/ /g, '+')}:wght@\${weightStr}&display=swap\`;
516
+ return <link key={url} rel="stylesheet" href={url} />;
517
+ })}
518
+ </>
519
+ );
520
+ };
521
+
522
+ const SurfaceComponent = (props: any) => (
523
+ <div style={{ width: '100%', minHeight: '100vh', boxSizing: 'border-box' }}>
524
+ <DynamicFontLoader fonts={props.metadata?.fonts} />
525
+ {props.children}
526
+ </div>
527
+ );
528
+
529
+ const GenericContainer = (props: any) => (
530
+ <div style={buildCssFromNode(props.layout, props.style, { isSurfaceRoot: props._isSurfaceRoot })}>{props.children}</div>
531
+ );
532
+
533
+ const TextComponent = (props: any) => (
534
+ <span style={buildCssFromNode(props.layout, props.style, { isText: true })}>{props.content || props.text || props.name}</span>
535
+ );
536
+
537
+ const ButtonComponent = (props: any) => {
538
+ const adjLayout = { ...props.layout };
539
+ if (adjLayout && !props.children && adjLayout.align_content === 'space-between') adjLayout.align_content = 'center';
540
+ const css: any = { ...buildCssFromNode(adjLayout, props.style), cursor: 'pointer', border: 'none' };
541
+ if (props.text_style) {
542
+ const tc = resolveColor(props.text_style.bg_color_token, props.text_style.bg_color);
543
+ if (tc) css.color = tc;
544
+ if (props.text_style.font_size) css.fontSize = props.text_style.font_size;
545
+ if (props.text_style.font_weight) css.fontWeight = props.text_style.font_weight;
546
+ if (props.text_style.font_family) css.fontFamily = props.text_style.font_family;
547
+ if (props.text_style.text_align) css.textAlign = props.text_style.text_align;
548
+ }
549
+ return <button style={css}>{props.children || props.content || props.text || props.name}</button>;
550
+ };
551
+
552
+ export const registry: Record<string, React.FC<any>> = {
553
+ surface: SurfaceComponent,
554
+ composition: GenericContainer,
555
+ frame: GenericContainer,
556
+ element: GenericContainer,
557
+ group: GenericContainer,
558
+ stack: GenericContainer,
559
+ text: TextComponent,
560
+ Text: TextComponent,
561
+ button: ButtonComponent,
562
+ input: (props: any) => <input style={buildCssFromNode(props.layout, props.style)} placeholder={props.content || props.name} />,
563
+ icon: (props: any) => <div style={{ ...buildCssFromNode(props.layout, props.style), display:'inline-flex', alignItems:'center', justifyContent:'center' }}>āš™ļø</div>,
564
+ };
565
+ `.trim());
566
+
567
+ console.log(chalk.green(`āœ… Done! Standalone App Scaffolded.`));
568
+
569
+ // 5. Add to pnpm-workspace.yaml if needed
570
+ const pnpmYamlPath = path.join(projectRoot, "pnpm-workspace.yaml");
571
+ if (fs.existsSync(pnpmYamlPath)) {
572
+ let yamlContent = fs.readFileSync(pnpmYamlPath, 'utf-8');
573
+ if (!yamlContent.includes("desen-workspace")) {
574
+ yamlContent += `\n - "desen-workspace"\n`;
575
+ fs.writeFileSync(pnpmYamlPath, yamlContent);
576
+ console.log(chalk.blue(`šŸ› ļø Added 'desen-workspace' to pnpm - workspace.yaml`));
577
+ }
578
+ }
579
+ } // End if(!package.json)
580
+
581
+ res.status(200).json({ success: true, message: `Successfully saved ${savedCount} files.` });
582
+ } catch (error: any) {
583
+ console.error(chalk.red("āŒ Sync Error:"), error);
584
+ res.status(500).json({ error: error.message });
585
+ }
586
+ });
587
+
588
+ app.listen(port, () => {
589
+ console.log(chalk.cyan(`\nšŸš€ DESEN Sync Daemon running on http://localhost:${port}`));
590
+ console.log(chalk.gray(` Waiting for Figma plugin 'Sync to Localhost' events...`));
591
+ });
592
+ }); // End setup action
593
+
594
+ program.parse(process.argv);
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }