@su-record/vibe 2.8.31 → 2.8.32

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,225 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * figma-extract.js — Figma REST API 디자인 추출 도구
5
+ *
6
+ * Usage:
7
+ * node figma-extract.js tree <fileKey> <nodeId> [--depth=10]
8
+ * node figma-extract.js images <fileKey> <nodeId> --out=<dir> [--depth=10]
9
+ * node figma-extract.js screenshot <fileKey> <nodeId> --out=<path>
10
+ *
11
+ * Token: ~/.vibe/config.json → credentials.figma.accessToken
12
+ * 또는 FIGMA_ACCESS_TOKEN env
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import os from 'os';
18
+
19
+ // ─── Config ─────────────────────────────────────────────────────────
20
+
21
+ const FIGMA_API = 'https://api.figma.com/v1';
22
+ const MAX_RETRIES = 3;
23
+ const INITIAL_DELAY_MS = 2000;
24
+
25
+ function loadToken() {
26
+ if (process.env.FIGMA_ACCESS_TOKEN) return process.env.FIGMA_ACCESS_TOKEN;
27
+ const configPath = path.join(os.homedir(), '.vibe', 'config.json');
28
+ try {
29
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
30
+ const token = config?.credentials?.figma?.accessToken;
31
+ if (token) return token;
32
+ } catch { /* ignore */ }
33
+ return null;
34
+ }
35
+
36
+ function fail(msg) { console.error(JSON.stringify({ error: msg })); process.exit(1); }
37
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
38
+
39
+ // ─── HTTP ───────────────────────────────────────────────────────────
40
+
41
+ async function apiFetch(endpoint, token) {
42
+ let lastErr = '';
43
+ for (let i = 0; i < MAX_RETRIES; i++) {
44
+ try {
45
+ const res = await fetch(`${FIGMA_API}${endpoint}`, { headers: { 'X-Figma-Token': token } });
46
+ if (res.status === 429) { await sleep(INITIAL_DELAY_MS * 2 ** i); continue; }
47
+ if (res.status === 403) fail('403 Forbidden — check token permissions');
48
+ if (res.status === 404) fail('404 — check fileKey/nodeId');
49
+ if (!res.ok) {
50
+ lastErr = `HTTP ${res.status}`;
51
+ if (res.status >= 500) { await sleep(INITIAL_DELAY_MS * 2 ** i); continue; }
52
+ fail(lastErr);
53
+ }
54
+ return await res.json();
55
+ } catch (e) {
56
+ lastErr = e.message;
57
+ if (i < MAX_RETRIES - 1) await sleep(INITIAL_DELAY_MS * 2 ** i);
58
+ }
59
+ }
60
+ fail(`Failed after ${MAX_RETRIES} retries: ${lastErr}`);
61
+ }
62
+
63
+ // ─── Color ──────────────────────────────────────────────────────────
64
+
65
+ function toCSS(c) {
66
+ if (!c) return null;
67
+ const r = Math.round(c.r * 255), g = Math.round(c.g * 255), b = Math.round(c.b * 255), a = c.a ?? 1;
68
+ if (a === 1) return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
69
+ return `rgba(${r}, ${g}, ${b}, ${+a.toFixed(2)})`;
70
+ }
71
+
72
+ // ─── CSS Extraction ─────────────────────────────────────────────────
73
+
74
+ function extractCSS(n) {
75
+ const css = {};
76
+ // Layout
77
+ if (n.layoutMode === 'VERTICAL') { css.display = 'flex'; css.flexDirection = 'column'; }
78
+ else if (n.layoutMode === 'HORIZONTAL') { css.display = 'flex'; css.flexDirection = 'row'; }
79
+ const axM = { MIN:'flex-start', CENTER:'center', MAX:'flex-end', SPACE_BETWEEN:'space-between' };
80
+ const crM = { MIN:'flex-start', CENTER:'center', MAX:'flex-end', BASELINE:'baseline' };
81
+ if (n.primaryAxisAlignItems && axM[n.primaryAxisAlignItems]) css.justifyContent = axM[n.primaryAxisAlignItems];
82
+ if (n.counterAxisAlignItems && crM[n.counterAxisAlignItems]) css.alignItems = crM[n.counterAxisAlignItems];
83
+ if (n.itemSpacing > 0) css.gap = `${n.itemSpacing}px`;
84
+ // Padding
85
+ const pt=n.paddingTop||0, pr=n.paddingRight||0, pb=n.paddingBottom||0, pl=n.paddingLeft||0;
86
+ if (pt||pr||pb||pl) css.padding = `${pt}px ${pr}px ${pb}px ${pl}px`;
87
+ // Size
88
+ if (n.absoluteBoundingBox) { css.width = `${Math.round(n.absoluteBoundingBox.width)}px`; css.height = `${Math.round(n.absoluteBoundingBox.height)}px`; }
89
+ // Position / overflow / opacity
90
+ if (n.layoutPositioning === 'ABSOLUTE') css.position = 'absolute';
91
+ if (n.clipsContent) css.overflow = 'hidden';
92
+ if (n.opacity != null && n.opacity < 1) css.opacity = n.opacity.toFixed(2);
93
+ // Blend
94
+ const bm = { MULTIPLY:'multiply', SCREEN:'screen', OVERLAY:'overlay', DARKEN:'darken', LIGHTEN:'lighten', COLOR_DODGE:'color-dodge', COLOR_BURN:'color-burn', HARD_LIGHT:'hard-light', SOFT_LIGHT:'soft-light', DIFFERENCE:'difference', EXCLUSION:'exclusion', HUE:'hue', SATURATION:'saturation', COLOR:'color', LUMINOSITY:'luminosity' };
95
+ if (n.blendMode && bm[n.blendMode]) css.mixBlendMode = bm[n.blendMode];
96
+ // Radius
97
+ if (n.cornerRadius > 0) css.borderRadius = `${n.cornerRadius}px`;
98
+ else if (n.rectangleCornerRadii) { const [a,b,c,d] = n.rectangleCornerRadii; css.borderRadius = `${a}px ${b}px ${c}px ${d}px`; }
99
+ // Fills
100
+ let imgRef;
101
+ for (const f of (n.fills||[]).filter(f=>f.visible!==false)) {
102
+ if (f.type === 'SOLID') css.backgroundColor = toCSS({ ...f.color, a: f.opacity ?? f.color?.a ?? 1 });
103
+ else if (f.type === 'IMAGE') imgRef = f.imageRef;
104
+ }
105
+ // Strokes
106
+ const stroke = (n.strokes||[]).find(s=>s.visible!==false&&s.type==='SOLID');
107
+ if (stroke && n.strokeWeight) css.border = `${n.strokeWeight}px solid ${toCSS(stroke.color)}`;
108
+ // Effects
109
+ const shadows = [];
110
+ for (const e of (n.effects||[]).filter(e=>e.visible!==false)) {
111
+ if (e.type==='DROP_SHADOW'||e.type==='INNER_SHADOW') {
112
+ const ins = e.type==='INNER_SHADOW'?'inset ':'';
113
+ shadows.push(`${ins}${e.offset?.x||0}px ${e.offset?.y||0}px ${e.radius||0}px ${e.spread||0}px ${toCSS(e.color)}`);
114
+ } else if (e.type==='LAYER_BLUR') css.filter = `blur(${e.radius}px)`;
115
+ else if (e.type==='BACKGROUND_BLUR') css.backdropFilter = `blur(${e.radius}px)`;
116
+ }
117
+ if (shadows.length) css.boxShadow = shadows.join(', ');
118
+ // Text
119
+ if (n.type === 'TEXT' && n.style) {
120
+ const s = n.style;
121
+ if (s.fontFamily) css.fontFamily = `'${s.fontFamily}', sans-serif`;
122
+ if (s.fontSize) css.fontSize = `${s.fontSize}px`;
123
+ if (s.fontWeight) css.fontWeight = String(s.fontWeight);
124
+ if (s.lineHeightPx) css.lineHeight = `${s.lineHeightPx}px`;
125
+ if (s.letterSpacing) css.letterSpacing = `${s.letterSpacing}px`;
126
+ const ta = { LEFT:'left', CENTER:'center', RIGHT:'right', JUSTIFIED:'justify' };
127
+ if (s.textAlignHorizontal && ta[s.textAlignHorizontal]) css.textAlign = ta[s.textAlignHorizontal];
128
+ const tf = (n.fills||[]).find(f=>f.visible!==false&&f.type==='SOLID');
129
+ if (tf) css.color = toCSS(tf.color);
130
+ }
131
+ return imgRef ? { ...css, _imageRef: imgRef } : css;
132
+ }
133
+
134
+ // ─── Tree ───────────────────────────────────────────────────────────
135
+
136
+ function walk(node) {
137
+ const css = extractCSS(node);
138
+ const r = { nodeId: node.id, name: node.name||'', type: node.type, size: null, css: {...css}, children: [] };
139
+ if (node.type==='TEXT' && node.characters) r.text = node.characters;
140
+ if (node.absoluteBoundingBox) r.size = { width: Math.round(node.absoluteBoundingBox.width), height: Math.round(node.absoluteBoundingBox.height) };
141
+ if (css._imageRef) { r.imageRef = css._imageRef; delete r.css._imageRef; }
142
+ if (node.children?.length) r.children = node.children.map(walk);
143
+ return r;
144
+ }
145
+
146
+ function collectRefs(node, set = new Set()) {
147
+ if (node.imageRef) set.add(node.imageRef);
148
+ (node.children||[]).forEach(c => collectRefs(c, set));
149
+ return set;
150
+ }
151
+
152
+ // ─── Commands ───────────────────────────────────────────────────────
153
+
154
+ async function cmdTree(token, fk, nid, depth) {
155
+ const dp = depth ? `&depth=${depth}` : '';
156
+ const data = await apiFetch(`/files/${fk}/nodes?ids=${nid}${dp}`, token);
157
+ const nd = data.nodes?.[nid];
158
+ if (!nd?.document) fail(`Node ${nid} not found`);
159
+ console.log(JSON.stringify(walk(nd.document), null, 2));
160
+ }
161
+
162
+ async function cmdImages(token, fk, nid, outDir, depth) {
163
+ if (!outDir) fail('--out required');
164
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
165
+ // tree → refs
166
+ const dp = depth ? `&depth=${depth}` : '';
167
+ const data = await apiFetch(`/files/${fk}/nodes?ids=${nid}${dp}`, token);
168
+ const nd = data.nodes?.[nid];
169
+ if (!nd?.document) fail(`Node ${nid} not found`);
170
+ const tree = walk(nd.document);
171
+ const refs = collectRefs(tree);
172
+ if (!refs.size) { console.log(JSON.stringify({ total: 0, images: {} })); return; }
173
+ // download
174
+ const allImg = await apiFetch(`/files/${fk}/images`, token);
175
+ const urls = allImg.meta?.images || {};
176
+ const imageMap = {};
177
+ const dl = [];
178
+ for (const ref of refs) {
179
+ const url = urls[ref];
180
+ if (!url) continue;
181
+ const out = path.join(outDir, ref.slice(0,16) + '.png');
182
+ dl.push(fetch(url).then(r=>r.arrayBuffer()).then(b=>{
183
+ fs.writeFileSync(out, Buffer.from(b));
184
+ const sz = fs.statSync(out).size;
185
+ if (sz > 0) imageMap[ref] = out;
186
+ }).catch(()=>{}));
187
+ }
188
+ await Promise.all(dl);
189
+ console.log(JSON.stringify({ total: Object.keys(imageMap).length, images: imageMap }, null, 2));
190
+ }
191
+
192
+ async function cmdScreenshot(token, fk, nid, outPath) {
193
+ if (!outPath) fail('--out required');
194
+ const data = await apiFetch(`/images/${fk}?ids=${nid}&format=png&scale=2`, token);
195
+ const url = data.images?.[nid];
196
+ if (!url) fail(`No image for ${nid}`);
197
+ const dir = path.dirname(outPath);
198
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
199
+ const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
200
+ fs.writeFileSync(outPath, buf);
201
+ console.log(JSON.stringify({ path: outPath, size: buf.length }));
202
+ }
203
+
204
+ // ─── CLI ────────────────────────────────────────────────────────────
205
+
206
+ const args = process.argv.slice(2);
207
+ const flags = {};
208
+ const pos = [];
209
+ for (const a of args) {
210
+ if (a.startsWith('--')) { const [k,v] = a.slice(2).split('='); flags[k] = v ?? ''; }
211
+ else pos.push(a);
212
+ }
213
+
214
+ const token = loadToken();
215
+ if (!token) fail('Figma token not found. Run: vibe figma setup <token>');
216
+
217
+ const [cmd, fk, nidRaw] = pos;
218
+ const nid = nidRaw?.replace(/-/g, ':');
219
+
220
+ switch (cmd) {
221
+ case 'tree': await cmdTree(token, fk, nid, flags.depth ? +flags.depth : undefined); break;
222
+ case 'images': await cmdImages(token, fk, nid, flags.out, flags.depth ? +flags.depth : 10); break;
223
+ case 'screenshot': await cmdScreenshot(token, fk, nid, flags.out); break;
224
+ default: console.log('Usage: node figma-extract.js <tree|images|screenshot> <fileKey> <nodeId> [flags]');
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "2.8.31",
3
+ "version": "2.8.32",
4
4
  "description": "AI Coding Framework for Claude Code — 49 agents, 41+ tools, multi-LLM orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",
@@ -235,11 +235,8 @@ Figma REST API로 노드 트리와 CSS 속성을 직접 추출한다.
235
235
  MCP 플러그인(get_design_context/get_metadata)은 사용하지 않는다.
236
236
 
237
237
  Bash:
238
- node -e "
239
- import { getTree } from './dist/infra/lib/figma/index.js';
240
- const tree = await getTree({ fileKey: '{fileKey}', nodeId: '{섹션.nodeId}', depth: 10 });
241
- console.log(JSON.stringify(tree));
242
- "
238
+ # [FIGMA_SCRIPT] = ~/.vibe/hooks/scripts/figma-extract.js
239
+ node "[FIGMA_SCRIPT]" tree {fileKey} {섹션.nodeId} --depth=10
243
240
 
244
241
  반환 (JSON):
245
242
  {
@@ -265,15 +262,7 @@ CSS는 Figma 노드 속성에서 직접 추출 — Tailwind 역변환 불필요:
265
262
  트리에서 imageRef가 있는 노드를 수집 → Figma API로 다운로드.
266
263
 
267
264
  Bash:
268
- node -e "
269
- import { getTree, getImages, collectImageRefs } from './dist/infra/lib/figma/index.js';
270
- const tree = await getTree({ fileKey: '{fileKey}', nodeId: '{섹션.nodeId}', depth: 10 });
271
- const refs = collectImageRefs(tree);
272
- const result = await getImages({
273
- fileKey: '{fileKey}', imageRefs: refs, outDir: 'images/{feature}/'
274
- });
275
- console.log(JSON.stringify(result));
276
- "
265
+ node "[FIGMA_SCRIPT]" images {fileKey} {섹션.nodeId} --out=images/{feature}/ --depth=10
277
266
 
278
267
  검증: result.total = refs.size (누락 0)
279
268
  전부 완료해야 c 단계로 진행.
@@ -397,8 +386,7 @@ Grep 체크:
397
386
 
398
387
  시각 검증:
399
388
  각 섹션 스크린샷:
400
- node -e "import { getScreenshot } from './dist/infra/lib/figma/index.js';
401
- await getScreenshot({ fileKey: '{fileKey}', nodeId: '{nodeId}', outPath: '/tmp/{section}.png' });"
389
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out=/tmp/{section}.png
402
390
  → dev 서버/preview와 비교
403
391
  P1 (필수): 이미지 누락, 레이아웃 구조 다름, 텍스트 스타일 미적용
404
392
  P2 (권장): 미세 간격, 미세 색상 차이
@@ -15,11 +15,8 @@ Figma REST API(`src/infra/lib/figma/`)를 사용하여 노드 트리, CSS, 이
15
15
 
16
16
  ```
17
17
  Bash:
18
- node -e "
19
- import { getTree } from './dist/infra/lib/figma/index.js';
20
- const tree = await getTree({ fileKey: '{fileKey}', nodeId: '{nodeId}', depth: 10 });
21
- console.log(JSON.stringify(tree));
22
- "
18
+ # [FIGMA_SCRIPT] = ~/.vibe/hooks/scripts/figma-extract.js
19
+ node "[FIGMA_SCRIPT]" tree {fileKey} {nodeId} --depth=10
23
20
 
24
21
  반환 (FigmaNode JSON):
25
22
  {
@@ -75,13 +72,7 @@ Bash:
75
72
  트리에서 imageRef 수집 → Figma API로 다운로드:
76
73
 
77
74
  Bash:
78
- node -e "
79
- import { getTree, getImages, collectImageRefs } from './dist/infra/lib/figma/index.js';
80
- const tree = await getTree({ fileKey: '{fileKey}', nodeId: '{nodeId}', depth: 10 });
81
- const refs = collectImageRefs(tree);
82
- const result = await getImages({ fileKey: '{fileKey}', imageRefs: refs, outDir: '{outDir}' });
83
- console.log(JSON.stringify(result));
84
- "
75
+ node "[FIGMA_SCRIPT]" images {fileKey} {nodeId} --out={outDir} --depth=10
85
76
 
86
77
  반환: { total: N, images: { "imageRef": "/path/to/file.png", ... } }
87
78
 
@@ -95,10 +86,7 @@ Bash:
95
86
 
96
87
  ```
97
88
  Bash:
98
- node -e "
99
- import { getScreenshot } from './dist/infra/lib/figma/index.js';
100
- await getScreenshot({ fileKey: '{fileKey}', nodeId: '{nodeId}', outPath: '{path}' });
101
- "
89
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out={path}
102
90
 
103
91
  시각 검증용. 노드를 PNG로 렌더링하여 저장.
104
92
  ```