desen-cli 1.0.0-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.
- package/.turbo/turbo-build.log +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +553 -0
- package/package.json +26 -0
- package/src/index.ts +594 -0
- package/tsconfig.json +13 -0
package/dist/index.d.ts
ADDED
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": "1.0.0-draft",
|
|
239
|
+
"desen": "1.0.0-draft",
|
|
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.0-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.0-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": "1.0.0-draft",
|
|
267
|
+
"desen": "1.0.0-draft",
|
|
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
|
+
}
|