raptor-aios 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/_core/dist/agents/prompt-builder.js +10 -8
- package/dist/_core/dist/audit/schema.js +1 -0
- package/dist/_core/dist/design/asset-normalize.js +37 -0
- package/dist/_core/dist/design/asset-paths.js +23 -0
- package/dist/_core/dist/design/downloader.js +140 -0
- package/dist/_core/dist/design/fetcher.js +203 -0
- package/dist/_core/dist/design/figma-client.js +198 -0
- package/dist/_core/dist/design/figma-credentials.js +41 -0
- package/dist/_core/dist/design/figma-dialects.js +75 -0
- package/dist/_core/dist/design/figma-oauth.js +71 -0
- package/dist/_core/dist/design/figma-rest.js +277 -0
- package/dist/_core/dist/design/font-resolver.js +298 -0
- package/dist/_core/dist/design/index.js +12 -0
- package/dist/_core/dist/design/mapper.js +22 -2
- package/dist/_core/dist/design/scaffold.js +58 -2
- package/dist/_core/dist/design/screens.js +202 -0
- package/dist/_core/dist/design/slug.js +9 -0
- package/dist/_core/dist/design/tokens.js +2 -1
- package/dist/_core/dist/gates/design-gates.js +1 -9
- package/dist/_core/dist/jira/mcp-client.js +15 -131
- package/dist/_core/dist/jira/oauth.js +1 -79
- package/dist/_core/dist/transport/index.js +2 -0
- package/dist/_core/dist/transport/mcp.js +134 -0
- package/dist/_core/dist/transport/oauth.js +83 -0
- package/dist/_core/package.json +1 -1
- package/dist/_core/templates/spec.md.hbs +28 -3
- package/dist/commands/design/connect.js +105 -0
- package/dist/commands/design/disconnect.js +19 -0
- package/dist/commands/design/pull.js +65 -0
- package/dist/commands/design/status.js +76 -0
- package/dist/commands/design/sync.js +55 -0
- package/dist/commands/new.js +36 -2
- package/dist/shared/design.js +93 -0
- package/package.json +1 -1
- package/scripts/prepare-npm.mjs +1 -1
- package/templates/commands/plan.md +5 -3
- package/templates/commands/specify.md +9 -5
- package/templates/commands/tasks.md +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.11.0] - 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Integração Figma/Design — captura determinística + seed + gate (`packages/core/src/design/`).** Um link do Figma em `raptor new --figma <url>` deixa de ser só uma URL na spec: o Raptor CAPTURA o design de forma determinística e SEMEIA `design/` com os artefatos reais (espelhando o modelo do enriquecimento Jira). A captura usa o provider **`figma-rest`** (REST API + Personal Access Token, determinístico e roda em CI), selecionável via `design.provider` no `raptor.yml`.
|
|
13
|
+
- **Tokens** via `GET /v1/files/:key/variables/local` (com fallback para `/styles` quando o endpoint dá `403` em planos não-Enterprise ou volta vazio), normalizados em `design/tokens.json` (colors/typography/spacing/radii) por uma classificação robusta a nomes inconsistentes do mundo real (o valor decide antes do nome; tipografia decomposta em family/size/weight/lineHeight/letterSpacing — um número nunca vira família).
|
|
14
|
+
- **Telas** via `GET /v1/files/:key/nodes` (nó linkado) ou `?depth=2` (arquivo inteiro) → `design/screens/<slug>.md`.
|
|
15
|
+
- **Assets** escopados ao frame: o render do nó (`/v1/images`) + os image fills que a subárvore realmente pinta (`collectImageRefs` sobre `/nodes`, filtrando o `/v1/files/:key/images` que devolve o arquivo inteiro) — baixados com `sha256`, guarda anti-SSRF e `max_bytes`, catalogados em `design/assets-manifest.json`.
|
|
16
|
+
- **Fontes** (o Figma não serve o binário) resolvidas por precedência repo/brand → Google Fonts → OS (`fc-match`) → `pending`.
|
|
17
|
+
- **Comandos** `raptor design connect | status | pull | sync | disconnect` (o `sync` re-semeia idempotente — nunca sobrescreve refino — e emite `design.synced`).
|
|
18
|
+
- **Flip REDISTRIBUIR**: `spec.md.hbs`, os prompts de comando (`specify`/`plan`/`tasks`) e o prompt canônico bifurcam — quando o design foi semeado, instruem o agente a **REFINAR e redistribuir** os artefatos em disco (referenciar por `[screen: X]`/`[asset: Y]`, refinar os `screens/*.md`, verificar os tokens, declarar as libs); sem captura, mantêm a instrução de **ENXERGAR** via Figma MCP. O `spec.md.hbs` lista o que foi semeado (contagem de tokens, telas, assets, libs).
|
|
19
|
+
- **`gate.design.ready`** cobra a incorporação na promoção da spec (telas/assets referenciados existem e estão em disco; tokens não-vazios).
|
|
20
|
+
- **Auditoria** enriquecida (`feature.created`/`design.imported` com contagens de tokens/telas/assets; novo evento `design.synced`).
|
|
21
|
+
- Validado E2E contra o arquivo Check-in real (`figma-rest`/PAT) e endurecido por revisão adversarial multi-dimensão: escopo de assets (1357 imagens espúrias → 0), classificação de tokens (63 → 93 bem distribuídos, 0 famílias falsas), memoização de `/nodes`. Doc: [`docs/design-figma-sync.md`](docs/design-figma-sync.md).
|
|
22
|
+
|
|
6
23
|
## [0.10.0] - 2026-06-11
|
|
7
24
|
|
|
8
25
|
### Added
|
|
@@ -33,9 +33,10 @@ const PHASE_MAP = {
|
|
|
33
33
|
" e todo id de AC é espelhado no frontmatter `acceptance.ids`.",
|
|
34
34
|
"6. Success Criteria mensuráveis e tecnologia-agnósticos (foco no usuário).",
|
|
35
35
|
"7. Auto-valide contra o 'Review & Acceptance Checklist' do template antes de entregar.",
|
|
36
|
-
"8. Se a feature tiver design
|
|
37
|
-
"
|
|
38
|
-
"
|
|
36
|
+
"8. Se a feature tiver design (pasta `design/` ou seção `## Design Reference`): se já houver",
|
|
37
|
+
" artefatos semeados (`design/tokens.json` populado, `design/screens/*.md`), REFINE e",
|
|
38
|
+
" redistribua esses artefatos em vez de re-buscar; senão use o Figma MCP para ENXERGAR o",
|
|
39
|
+
" design. Derive telas/fluxos/critérios das telas reais e refine (ou crie) `design/screens/<nome>.md`.",
|
|
39
40
|
"",
|
|
40
41
|
"### Feature a especificar:",
|
|
41
42
|
"",
|
|
@@ -67,8 +68,9 @@ const PHASE_MAP = {
|
|
|
67
68
|
"3. Inclua Data Model, Contracts e Research quando aplicável.",
|
|
68
69
|
"4. Gere Complexity Tracking se algum gate for violado.",
|
|
69
70
|
"5. Não invente onde a spec tem `[NEEDS CLARIFICATION]`.",
|
|
70
|
-
"6. Se a feature tiver design (pasta `design/`)
|
|
71
|
-
"
|
|
71
|
+
"6. Se a feature tiver design (pasta `design/`): se `design/tokens.json` já estiver populado,",
|
|
72
|
+
" VERIFIQUE/corrija os tokens semeados; senão extraia via Figma MCP. Declare as",
|
|
73
|
+
" bibliotecas/design-system necessárias no plan.",
|
|
72
74
|
].join("\n"),
|
|
73
75
|
},
|
|
74
76
|
tasks: {
|
|
@@ -97,9 +99,9 @@ const PHASE_MAP = {
|
|
|
97
99
|
"3. Inclua critério de aceite técnico por task.",
|
|
98
100
|
"4. Ordene pela dependência natural (build-order).",
|
|
99
101
|
"5. Toda task deve ser completável em ≤4h.",
|
|
100
|
-
"6. Se a feature tiver design (pasta `design/`)
|
|
101
|
-
"
|
|
102
|
-
"
|
|
102
|
+
"6. Se a feature tiver design (pasta `design/`): consuma os assets já catalogados em",
|
|
103
|
+
" `design/assets-manifest.json` (baixados em `assets/{images,icons,fonts}`) e baixe via",
|
|
104
|
+
" Figma MCP só o que faltar; referencie por caminho concreto (tags `[screen: X]` / `[asset: X]`).",
|
|
103
105
|
].join("\n"),
|
|
104
106
|
},
|
|
105
107
|
implement: {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { slugify } from "./slug.js";
|
|
2
|
+
import { assetDirFor, classifyAssetType, extForFormat, extFromUrl } from "./asset-paths.js";
|
|
3
|
+
export function normalizeImagesToAssets(renders, fills, opts = {}) {
|
|
4
|
+
const out = [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const fmt = opts.format ?? "png";
|
|
7
|
+
const renderType = classifyAssetType(fmt);
|
|
8
|
+
const renderExt = extForFormat(fmt);
|
|
9
|
+
const renderDir = assetDirFor(renderType);
|
|
10
|
+
for (const [nodeId, url] of Object.entries(renders)) {
|
|
11
|
+
if (!url)
|
|
12
|
+
continue;
|
|
13
|
+
const human = opts.names?.[nodeId];
|
|
14
|
+
let base = slugify(human || `node ${nodeId}`) || slugify(`node ${nodeId}`);
|
|
15
|
+
let path = `${renderDir}/${base}.${renderExt}`;
|
|
16
|
+
if (seen.has(path)) {
|
|
17
|
+
base = `${base}-${slugify(nodeId)}`;
|
|
18
|
+
path = `${renderDir}/${base}.${renderExt}`;
|
|
19
|
+
}
|
|
20
|
+
seen.add(path);
|
|
21
|
+
out.push({ name: human || base, type: renderType, path, url, figma_node_id: nodeId });
|
|
22
|
+
}
|
|
23
|
+
for (const [ref, url] of Object.entries(fills)) {
|
|
24
|
+
if (!url)
|
|
25
|
+
continue;
|
|
26
|
+
let base = slugify(`fill ${ref}`).slice(0, 40) || slugify(`fill ${ref}`);
|
|
27
|
+
const ext = extFromUrl(url) ?? "png";
|
|
28
|
+
let path = `assets/images/${base}.${ext}`;
|
|
29
|
+
if (seen.has(path)) {
|
|
30
|
+
base = `${base}-${slugify(ref)}`.slice(0, 60);
|
|
31
|
+
path = `assets/images/${base}.${ext}`;
|
|
32
|
+
}
|
|
33
|
+
seen.add(path);
|
|
34
|
+
out.push({ name: base, type: "image", path, url });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function classifyAssetType(format) {
|
|
2
|
+
return format.trim().toLowerCase() === "svg" ? "svg" : "image";
|
|
3
|
+
}
|
|
4
|
+
export function assetDirFor(type) {
|
|
5
|
+
if (type === "svg" || type === "icon")
|
|
6
|
+
return "assets/icons";
|
|
7
|
+
if (type === "font")
|
|
8
|
+
return "assets/fonts";
|
|
9
|
+
return "assets/images";
|
|
10
|
+
}
|
|
11
|
+
export function extForFormat(format) {
|
|
12
|
+
const f = format.trim().toLowerCase();
|
|
13
|
+
if (!/^[a-z0-9]{2,5}$/.test(f))
|
|
14
|
+
return "png";
|
|
15
|
+
return f === "jpeg" ? "jpg" : f;
|
|
16
|
+
}
|
|
17
|
+
export function extFromUrl(url) {
|
|
18
|
+
const m = url.match(/\.([A-Za-z0-9]{2,5})(?:[?#]|$)/);
|
|
19
|
+
if (!m)
|
|
20
|
+
return null;
|
|
21
|
+
const ext = m[1].toLowerCase();
|
|
22
|
+
return ext === "jpeg" ? "jpg" : ext;
|
|
23
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { normalizePath } from "./slug.js";
|
|
5
|
+
export class DownloadError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = "DownloadError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function downloadAsset(url, destAbs, opts = {}) {
|
|
14
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
15
|
+
const max = opts.maxBytes && opts.maxBytes > 0 ? opts.maxBytes : 0;
|
|
16
|
+
assertSafeAssetUrl(url, opts.allowHttp ?? false);
|
|
17
|
+
let res;
|
|
18
|
+
try {
|
|
19
|
+
res = await doFetch(url, opts.headers ? { headers: opts.headers } : {});
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
throw new DownloadError(`download failed for ${url}: ${err instanceof Error ? err.message : String(err)}`, 0);
|
|
23
|
+
}
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new DownloadError(`download ${url} → ${res.status} ${res.statusText}`, res.status);
|
|
26
|
+
}
|
|
27
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
28
|
+
const declared = Number(res.headers.get("content-length") ?? "");
|
|
29
|
+
if (max && Number.isFinite(declared) && declared > max) {
|
|
30
|
+
throw new DownloadError(`download ${url} exceeds max_bytes (${declared} > ${max})`, res.status);
|
|
31
|
+
}
|
|
32
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
33
|
+
if (max && buf.length > max) {
|
|
34
|
+
throw new DownloadError(`download ${url} exceeds max_bytes (${buf.length} > ${max})`, res.status);
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
37
|
+
writeFileSync(destAbs, buf);
|
|
38
|
+
const hash = createHash("sha256").update(buf).digest("hex");
|
|
39
|
+
return { hash, bytes: buf.length, ...(contentType ? { contentType } : {}) };
|
|
40
|
+
}
|
|
41
|
+
export async function downloadAll(pending, root, opts = {}) {
|
|
42
|
+
const concurrency = Math.max(1, opts.concurrency ?? 4);
|
|
43
|
+
const results = new Array(pending.length);
|
|
44
|
+
let next = 0;
|
|
45
|
+
const worker = async () => {
|
|
46
|
+
for (;;) {
|
|
47
|
+
const i = next++;
|
|
48
|
+
if (i >= pending.length)
|
|
49
|
+
return;
|
|
50
|
+
const a = pending[i];
|
|
51
|
+
if (!a)
|
|
52
|
+
return;
|
|
53
|
+
const base = {
|
|
54
|
+
name: a.name,
|
|
55
|
+
type: a.type,
|
|
56
|
+
path: a.path,
|
|
57
|
+
...(a.figma_node_id ? { figma_node_id: a.figma_node_id } : {}),
|
|
58
|
+
...(a.screen ? { screen: a.screen } : {}),
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const rel = normalizePath(a.path);
|
|
62
|
+
const rootAbs = resolve(root);
|
|
63
|
+
const within = resolve(rootAbs, rel);
|
|
64
|
+
if (isAbsolute(rel) || (within !== rootAbs && !within.startsWith(rootAbs + sep))) {
|
|
65
|
+
throw new DownloadError(`asset path escapes project root: ${a.path}`, 0);
|
|
66
|
+
}
|
|
67
|
+
const destAbs = join(root, rel);
|
|
68
|
+
const { hash } = await downloadAsset(a.url, destAbs, opts);
|
|
69
|
+
results[i] = { ...base, hash, status: "downloaded" };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (opts.onWarn)
|
|
74
|
+
opts.onWarn(`asset "${a.name}" not downloaded: ${msg}`);
|
|
75
|
+
else
|
|
76
|
+
process.stderr.write(`Warning: asset "${a.name}" not downloaded: ${msg}\n`);
|
|
77
|
+
results[i] = { ...base, status: "pending" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
function assertSafeAssetUrl(url, allowHttp) {
|
|
85
|
+
let u;
|
|
86
|
+
try {
|
|
87
|
+
u = new URL(url);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new DownloadError(`invalid asset URL: ${url}`, 0);
|
|
91
|
+
}
|
|
92
|
+
if (u.protocol !== "https:" && !(allowHttp && u.protocol === "http:")) {
|
|
93
|
+
throw new DownloadError(`refusing non-https asset URL (${u.protocol}//…)`, 0);
|
|
94
|
+
}
|
|
95
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
96
|
+
if (isInternalHost(host)) {
|
|
97
|
+
throw new DownloadError(`refusing asset URL to internal host: ${host}`, 0);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isInternalHost(host) {
|
|
101
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
102
|
+
return true;
|
|
103
|
+
if (host === "metadata.google.internal")
|
|
104
|
+
return true;
|
|
105
|
+
if (host.includes(":")) {
|
|
106
|
+
if (host === "::1" || host === "::")
|
|
107
|
+
return true;
|
|
108
|
+
if (host.startsWith("fe80:"))
|
|
109
|
+
return true;
|
|
110
|
+
if (/^f[cd][0-9a-f]{2}:/.test(host))
|
|
111
|
+
return true;
|
|
112
|
+
if (host.startsWith("::ffff:")) {
|
|
113
|
+
const rest = host.slice("::ffff:".length);
|
|
114
|
+
if (rest.includes("."))
|
|
115
|
+
return isInternalHost(rest);
|
|
116
|
+
const hx = rest.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
117
|
+
if (hx) {
|
|
118
|
+
const hi = parseInt(hx[1], 16);
|
|
119
|
+
const lo = parseInt(hx[2], 16);
|
|
120
|
+
return isInternalHost(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
126
|
+
if (m) {
|
|
127
|
+
const a = Number(m[1]);
|
|
128
|
+
const b = Number(m[2]);
|
|
129
|
+
if (a === 0 || a === 10 || a === 127)
|
|
130
|
+
return true;
|
|
131
|
+
if (a === 169 && b === 254)
|
|
132
|
+
return true;
|
|
133
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
134
|
+
return true;
|
|
135
|
+
if (a === 192 && b === 168)
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { downloadAll } from "./downloader.js";
|
|
2
|
+
import { resolveFonts } from "./font-resolver.js";
|
|
3
|
+
function warn(opts, message) {
|
|
4
|
+
if (opts.onWarn)
|
|
5
|
+
opts.onWarn(message);
|
|
6
|
+
else
|
|
7
|
+
process.stderr.write(`Warning: ${message}\n`);
|
|
8
|
+
}
|
|
9
|
+
export async function fetchDesignData(refs, client, opts = {}) {
|
|
10
|
+
const varMap = {};
|
|
11
|
+
for (const ref of refs) {
|
|
12
|
+
try {
|
|
13
|
+
const m = await client.getVariableDefs(ref.fileKey, ref.nodeId);
|
|
14
|
+
Object.assign(varMap, m);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
warn(opts, `Figma variables read failed for ${ref.url}: ${errMsg(err)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const libraries = new Set();
|
|
21
|
+
if (client.getLibraries) {
|
|
22
|
+
for (const ref of refs) {
|
|
23
|
+
try {
|
|
24
|
+
for (const lib of await client.getLibraries(ref.fileKey))
|
|
25
|
+
libraries.add(lib);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
warn(opts, `Figma libraries read failed for ${ref.url}: ${errMsg(err)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const tokens = normalizeVariablesToTokens(varMap, opts.source);
|
|
33
|
+
const screens = await captureScreens(refs, client, opts);
|
|
34
|
+
const assets = [];
|
|
35
|
+
if (opts.root) {
|
|
36
|
+
assets.push(...(await captureAssets(refs, client, tokens.typography, opts)));
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
tokens,
|
|
40
|
+
screens,
|
|
41
|
+
assets,
|
|
42
|
+
libraries: [...libraries],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function captureScreens(refs, client, opts) {
|
|
46
|
+
if (!client.captureScreens)
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const ref of refs) {
|
|
51
|
+
try {
|
|
52
|
+
for (const s of await client.captureScreens(ref.fileKey, ref.nodeId)) {
|
|
53
|
+
const key = s.nodeId
|
|
54
|
+
? `id:${s.file ?? ""}#${s.nodeId}`
|
|
55
|
+
: `nm:${s.file ?? ""}#${s.name}#${s.summary ?? ""}`;
|
|
56
|
+
if (seen.has(key))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
out.push(s);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
warn(opts, `Figma screens read failed for ${ref.url}: ${errMsg(err)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
async function captureAssets(refs, client, typography, opts) {
|
|
69
|
+
const root = opts.root;
|
|
70
|
+
const format = opts.assets?.format ?? "svg";
|
|
71
|
+
const scale = opts.assets?.scale ?? 2;
|
|
72
|
+
const maxBytes = opts.assets?.maxBytes ?? 5_000_000;
|
|
73
|
+
const fetchImpl = opts.deps?.fetchImpl;
|
|
74
|
+
const pending = [];
|
|
75
|
+
const seenPaths = new Set();
|
|
76
|
+
if (client.captureAssets) {
|
|
77
|
+
for (const ref of refs) {
|
|
78
|
+
try {
|
|
79
|
+
const found = await client.captureAssets(ref.fileKey, ref.nodeId, { format, scale });
|
|
80
|
+
for (const p of found) {
|
|
81
|
+
if (seenPaths.has(p.path))
|
|
82
|
+
continue;
|
|
83
|
+
seenPaths.add(p.path);
|
|
84
|
+
pending.push(p);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
warn(opts, `Figma asset read failed for ${ref.url}: ${errMsg(err)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const out = [];
|
|
93
|
+
if (pending.length) {
|
|
94
|
+
const entries = await downloadAll(pending, root, {
|
|
95
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
96
|
+
maxBytes,
|
|
97
|
+
...(opts.concurrency ? { concurrency: opts.concurrency } : {}),
|
|
98
|
+
...(opts.onWarn ? { onWarn: opts.onWarn } : {}),
|
|
99
|
+
});
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
if (e.status === "downloaded")
|
|
102
|
+
out.push(e);
|
|
103
|
+
else
|
|
104
|
+
warn(opts, `asset "${e.name}" could not be downloaded — left pending (not catalogued)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const fontEntries = await resolveFonts(typography, {
|
|
108
|
+
root,
|
|
109
|
+
brandDir: opts.fonts?.brandDir ?? "assets/fonts",
|
|
110
|
+
google: opts.fonts?.google !== false,
|
|
111
|
+
os: opts.fonts?.os !== false,
|
|
112
|
+
maxBytes,
|
|
113
|
+
deps: {
|
|
114
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
115
|
+
...(opts.deps?.fcMatch ? { fcMatch: opts.deps.fcMatch } : {}),
|
|
116
|
+
...(opts.deps?.osFontDirs ? { osFontDirs: opts.deps.osFontDirs } : {}),
|
|
117
|
+
},
|
|
118
|
+
...(opts.onWarn ? { onWarn: opts.onWarn } : {}),
|
|
119
|
+
});
|
|
120
|
+
out.push(...fontEntries);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
export function isColorValue(value) {
|
|
124
|
+
const v = value.trim().toLowerCase();
|
|
125
|
+
return /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(v) || /^rgba?\(/.test(v);
|
|
126
|
+
}
|
|
127
|
+
export function isNumericValue(value) {
|
|
128
|
+
return /^-?\d+(\.\d+)?(px|rem|em|pt|%)?$/.test(value.trim());
|
|
129
|
+
}
|
|
130
|
+
export function classifyToken(name, value) {
|
|
131
|
+
const lname = name.toLowerCase();
|
|
132
|
+
if (isColorValue(value))
|
|
133
|
+
return "color";
|
|
134
|
+
if (value === "true" || value === "false")
|
|
135
|
+
return "unknown";
|
|
136
|
+
if (/(radius|radii|corner|rounded)/.test(lname))
|
|
137
|
+
return "radius";
|
|
138
|
+
if (/(font|typograph|leading|line-?height|letter-?spacing|\btext\b|\btype\b)/.test(lname)) {
|
|
139
|
+
return "typography";
|
|
140
|
+
}
|
|
141
|
+
if (!isNumericValue(value) && /(colou?r|fill|bg|background|surface|border|stroke|brand|shadow)/.test(lname)) {
|
|
142
|
+
return "color";
|
|
143
|
+
}
|
|
144
|
+
if (/(spac|sizing|gap|pad|margin|inset|\bsize\b|dimension|width|height)/.test(lname)) {
|
|
145
|
+
return "spacing";
|
|
146
|
+
}
|
|
147
|
+
return "unknown";
|
|
148
|
+
}
|
|
149
|
+
function typographyField(name, value) {
|
|
150
|
+
const ln = name.toLowerCase();
|
|
151
|
+
if (/weight/.test(ln))
|
|
152
|
+
return { fontWeight: value };
|
|
153
|
+
if (/(line-?height|leading)/.test(ln))
|
|
154
|
+
return { lineHeight: value };
|
|
155
|
+
if (/(letter-?spacing|tracking)/.test(ln))
|
|
156
|
+
return { letterSpacing: value };
|
|
157
|
+
if (/size/.test(ln) || isNumericValue(value))
|
|
158
|
+
return { fontSize: value };
|
|
159
|
+
return { fontFamily: value };
|
|
160
|
+
}
|
|
161
|
+
export function normalizeVariablesToTokens(map, source) {
|
|
162
|
+
const colors = [];
|
|
163
|
+
const spacing = [];
|
|
164
|
+
const radii = [];
|
|
165
|
+
const typography = [];
|
|
166
|
+
for (const [rawName, rawValue] of Object.entries(map)) {
|
|
167
|
+
const name = rawName.trim();
|
|
168
|
+
const value = String(rawValue).trim();
|
|
169
|
+
if (!name || !value)
|
|
170
|
+
continue;
|
|
171
|
+
switch (classifyToken(name, value)) {
|
|
172
|
+
case "color":
|
|
173
|
+
colors.push({ name, value });
|
|
174
|
+
break;
|
|
175
|
+
case "radius":
|
|
176
|
+
radii.push({ name, value });
|
|
177
|
+
break;
|
|
178
|
+
case "spacing":
|
|
179
|
+
spacing.push({ name, value });
|
|
180
|
+
break;
|
|
181
|
+
case "typography":
|
|
182
|
+
typography.push({ name, ...typographyField(name, value) });
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const tokens = { colors, typography, spacing };
|
|
187
|
+
if (radii.length)
|
|
188
|
+
tokens.radii = radii;
|
|
189
|
+
if (source) {
|
|
190
|
+
tokens.source = {
|
|
191
|
+
provider: source.provider,
|
|
192
|
+
...(source.fileKey ? { fileKey: source.fileKey } : {}),
|
|
193
|
+
...(source.generatedAt ? { generatedAt: source.generatedAt } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return tokens;
|
|
197
|
+
}
|
|
198
|
+
export function seededTokensCount(t) {
|
|
199
|
+
return t.colors.length + t.typography.length + t.spacing.length + (t.radii?.length ?? 0);
|
|
200
|
+
}
|
|
201
|
+
function errMsg(err) {
|
|
202
|
+
return err instanceof Error ? err.message : String(err);
|
|
203
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { connectOAuthMcp, connectStdioMcp, } from "../transport/mcp.js";
|
|
2
|
+
import { FIGMA_REMOTE_DIALECT, detectFigmaDialect, resolveFigmaDialect, } from "./figma-dialects.js";
|
|
3
|
+
import { FigmaOAuthProvider, FIGMA_MCP_URL, openBrowser, startCallbackServer, } from "./figma-oauth.js";
|
|
4
|
+
import { assetDirFor, classifyAssetType, extForFormat, extFromUrl, } from "./asset-paths.js";
|
|
5
|
+
import { normalizeMetadataToScreens, pageIdsFromMetadata } from "./screens.js";
|
|
6
|
+
import { slugify } from "./slug.js";
|
|
7
|
+
export function makeFigmaMcpClient(caller, opts = {}) {
|
|
8
|
+
const dialect = opts.dialect ?? detectFigmaDialect(caller.toolNames) ?? FIGMA_REMOTE_DIALECT;
|
|
9
|
+
const resolve = (op) => {
|
|
10
|
+
const candidates = dialect.toolNames[op];
|
|
11
|
+
const found = candidates.find((n) => caller.toolNames.includes(n));
|
|
12
|
+
if (found)
|
|
13
|
+
return found;
|
|
14
|
+
const advertised = caller.toolNames.slice(0, 12).join(", ");
|
|
15
|
+
throw new Error(`No Figma MCP tool for "${op}": dialect "${dialect.name}" expects one of ` +
|
|
16
|
+
`[${candidates.join(", ")}], but the server advertises [${advertised}` +
|
|
17
|
+
`${caller.toolNames.length > 12 ? ", …" : ""}]. ` +
|
|
18
|
+
`Set design.mcp.dialect in raptor.yml to match your server.`);
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
async getVariableDefs(fileKey, nodeId) {
|
|
22
|
+
const data = await caller.call(resolve("variables"), dialect.args.variables(fileKey, nodeId));
|
|
23
|
+
return coerceVarMap(data);
|
|
24
|
+
},
|
|
25
|
+
async getMetadata(fileKey, nodeId) {
|
|
26
|
+
return caller.call(resolve("metadata"), dialect.args.metadata(fileKey, nodeId));
|
|
27
|
+
},
|
|
28
|
+
async getLibraries(fileKey) {
|
|
29
|
+
const data = await caller.call(resolve("libraries"), dialect.args.libraries(fileKey));
|
|
30
|
+
return coerceLibraries(data);
|
|
31
|
+
},
|
|
32
|
+
async captureAssets(fileKey, nodeId, assetOpts = {}) {
|
|
33
|
+
const data = await caller.call(resolve("downloadAssets"), dialect.args.downloadAssets({
|
|
34
|
+
fileKey,
|
|
35
|
+
nodeId,
|
|
36
|
+
...(assetOpts.format ? { format: assetOpts.format } : {}),
|
|
37
|
+
...(assetOpts.scale ? { scale: assetOpts.scale } : {}),
|
|
38
|
+
}));
|
|
39
|
+
return coerceDownloadAssets(data, { format: assetOpts.format, nodeId });
|
|
40
|
+
},
|
|
41
|
+
async captureScreens(fileKey, nodeId) {
|
|
42
|
+
const data = await caller.call(resolve("metadata"), dialect.args.metadata(fileKey, nodeId));
|
|
43
|
+
const screens = normalizeMetadataToScreens(data, fileKey, nodeId);
|
|
44
|
+
if (screens.length || nodeId)
|
|
45
|
+
return screens;
|
|
46
|
+
const out = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const pageId of pageIdsFromMetadata(data)) {
|
|
49
|
+
const pageData = await caller.call(resolve("metadata"), dialect.args.metadata(fileKey, pageId));
|
|
50
|
+
for (const s of normalizeMetadataToScreens(pageData, fileKey)) {
|
|
51
|
+
const key = s.nodeId || slugify(s.name);
|
|
52
|
+
if (!key || seen.has(key))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
out.push(s);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
},
|
|
60
|
+
close: () => caller.close(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function connectFigmaMcpStdio(cfg) {
|
|
64
|
+
const caller = await connectStdioMcp(cfg, { label: "Figma MCP" });
|
|
65
|
+
return makeFigmaMcpClient(caller, cfg.dialect ? { dialect: resolveFigmaDialect(cfg.dialect) } : {});
|
|
66
|
+
}
|
|
67
|
+
export async function connectFigmaMcp(opts = {}) {
|
|
68
|
+
const callback = await startCallbackServer({ productName: "Figma" });
|
|
69
|
+
let authUrl;
|
|
70
|
+
const provider = new FigmaOAuthProvider({
|
|
71
|
+
redirectUrl: callback.redirectUrl,
|
|
72
|
+
onAuthorizationUrl: (url) => {
|
|
73
|
+
authUrl = url;
|
|
74
|
+
if (opts.onAuthorizationUrl)
|
|
75
|
+
return opts.onAuthorizationUrl(url);
|
|
76
|
+
openBrowser(url.toString());
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const caller = await connectOAuthMcp({
|
|
80
|
+
provider,
|
|
81
|
+
serverUrl: new URL(opts.serverUrl ?? FIGMA_MCP_URL),
|
|
82
|
+
callback,
|
|
83
|
+
onWaiting: () => {
|
|
84
|
+
if (authUrl)
|
|
85
|
+
opts.onWaiting?.(authUrl);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return makeFigmaMcpClient(caller);
|
|
89
|
+
}
|
|
90
|
+
function isRecord(v) {
|
|
91
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
92
|
+
}
|
|
93
|
+
export function coerceVarMap(data) {
|
|
94
|
+
const src = isRecord(data) && isRecord(data["variables"]) ? data["variables"] : data;
|
|
95
|
+
if (!isRecord(src))
|
|
96
|
+
return {};
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [k, v] of Object.entries(src)) {
|
|
99
|
+
if (v == null)
|
|
100
|
+
continue;
|
|
101
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
102
|
+
out[k] = String(v);
|
|
103
|
+
}
|
|
104
|
+
else if (isRecord(v) && (typeof v["value"] === "string" || typeof v["value"] === "number")) {
|
|
105
|
+
out[k] = String(v["value"]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
const URL_KEYS = ["url", "downloadUrl", "download_url", "src", "imageUrl", "image_url"];
|
|
111
|
+
const NAME_KEYS = ["name", "fileName", "filename"];
|
|
112
|
+
const NODE_KEYS = ["nodeId", "node_id"];
|
|
113
|
+
function collectUrlItems(data, depth = 0, out = []) {
|
|
114
|
+
if (depth > 6 || data == null)
|
|
115
|
+
return out;
|
|
116
|
+
if (Array.isArray(data)) {
|
|
117
|
+
for (const el of data)
|
|
118
|
+
collectUrlItems(el, depth + 1, out);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
if (typeof data !== "object")
|
|
122
|
+
return out;
|
|
123
|
+
const rec = data;
|
|
124
|
+
const urlKey = URL_KEYS.find((k) => typeof rec[k] === "string" && rec[k].startsWith("http"));
|
|
125
|
+
if (urlKey) {
|
|
126
|
+
const item = { url: rec[urlKey] };
|
|
127
|
+
if (typeof rec["format"] === "string")
|
|
128
|
+
item.format = rec["format"];
|
|
129
|
+
const nameKey = NAME_KEYS.find((k) => typeof rec[k] === "string");
|
|
130
|
+
if (nameKey)
|
|
131
|
+
item.name = rec[nameKey];
|
|
132
|
+
const nodeKey = NODE_KEYS.find((k) => typeof rec[k] === "string");
|
|
133
|
+
if (nodeKey)
|
|
134
|
+
item.nodeId = rec[nodeKey];
|
|
135
|
+
out.push(item);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
for (const v of Object.values(rec)) {
|
|
139
|
+
if (v && typeof v === "object")
|
|
140
|
+
collectUrlItems(v, depth + 1, out);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
export function coerceDownloadAssets(data, opts = {}) {
|
|
145
|
+
const items = collectUrlItems(data);
|
|
146
|
+
const out = [];
|
|
147
|
+
const seenUrls = new Set();
|
|
148
|
+
const seenPaths = new Set();
|
|
149
|
+
let i = 0;
|
|
150
|
+
for (const it of items) {
|
|
151
|
+
if (seenUrls.has(it.url))
|
|
152
|
+
continue;
|
|
153
|
+
seenUrls.add(it.url);
|
|
154
|
+
const fmt = (it.format ?? opts.format ?? extFromUrl(it.url) ?? "png").toLowerCase();
|
|
155
|
+
const type = classifyAssetType(fmt);
|
|
156
|
+
const ext = extFromUrl(it.url) ?? extForFormat(fmt);
|
|
157
|
+
const rawName = it.name && it.name.trim() ? it.name.trim() : undefined;
|
|
158
|
+
const nameSlug = rawName ? slugify(rawName) : "";
|
|
159
|
+
const nodeSlug = it.nodeId ? slugify(it.nodeId) : "";
|
|
160
|
+
let key = [nameSlug, nodeSlug].filter(Boolean).join("-") || `asset-${i}`;
|
|
161
|
+
let path = `${assetDirFor(type)}/${key}.${ext}`;
|
|
162
|
+
while (seenPaths.has(path)) {
|
|
163
|
+
key = `${key}-${i}`;
|
|
164
|
+
path = `${assetDirFor(type)}/${key}.${ext}`;
|
|
165
|
+
}
|
|
166
|
+
seenPaths.add(path);
|
|
167
|
+
i++;
|
|
168
|
+
const attrNode = it.nodeId ?? opts.nodeId;
|
|
169
|
+
out.push({
|
|
170
|
+
name: rawName ?? key,
|
|
171
|
+
type,
|
|
172
|
+
path,
|
|
173
|
+
url: it.url,
|
|
174
|
+
...(attrNode ? { figma_node_id: attrNode } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
export function coerceLibraries(data) {
|
|
180
|
+
const arr = Array.isArray(data)
|
|
181
|
+
? data
|
|
182
|
+
: isRecord(data) && Array.isArray(data["libraries"])
|
|
183
|
+
? data["libraries"]
|
|
184
|
+
: isRecord(data) && Array.isArray(data["values"])
|
|
185
|
+
? data["values"]
|
|
186
|
+
: [];
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const item of arr) {
|
|
189
|
+
if (typeof item === "string" && item.trim())
|
|
190
|
+
out.push(item.trim());
|
|
191
|
+
else if (isRecord(item)) {
|
|
192
|
+
const name = item["name"] ?? item["libraryName"] ?? item["key"];
|
|
193
|
+
if (typeof name === "string" && name.trim())
|
|
194
|
+
out.push(name.trim());
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return [...new Set(out)];
|
|
198
|
+
}
|