From 4fc699fee6aa124717d899b7675a591f01325d4e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 15:14:57 -0700 Subject: [PATCH] fix(core,cli): parse figma 403 body, batch asset fetch, fix NO_TOKEN box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the scope+retry work from the figma bug-bash (valid report: 9-bugs-with-repros; the skill-not-used report was discarded). - 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN with re-mint advice; a scope body surfaces figma's own diagnosis verbatim; else falls back to the endpoint's scope hint. Reads both err and message (variables endpoint uses message). One fix, honest messages for bugs 1/4/9. - Batch asset fetch (requested): figma asset accepts multiple refs (space-separated or comma-joined) of one file and renders them in a SINGLE /v1/images call via new client.renderNodes — figma's documented per-minute rate-limit workaround. runAssetImport delegates to runAssetImportMany; cache-checks per node, batches only the misses, one index.md regen. - NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling the numbered setup list. Indent every line; single-line hints unchanged. Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not scope advice. Client suite 22, cli figma 33. Co-Authored-By: Claude Fable 5 --- packages/cli/src/commands/figma/asset.test.ts | 51 ++++- packages/cli/src/commands/figma/asset.ts | 203 +++++++++++++----- .../cli/src/commands/figma/component.test.ts | 14 ++ packages/cli/src/ui/format.ts | 11 +- packages/core/src/figma/client.test.ts | 73 ++++++- packages/core/src/figma/client.ts | 126 ++++++++--- skills-manifest.json | 2 +- skills/figma/SKILL.md | 4 +- 8 files changed, 400 insertions(+), 84 deletions(-) diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index c3e25a340..13947b2b0 100644 --- a/packages/cli/src/commands/figma/asset.test.ts +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, afterEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runAssetImport, type AssetImportDeps } from "./asset.js"; +import { runAssetImport, runAssetImportMany, type AssetImportDeps } from "./asset.js"; import type { FigmaClient } from "@hyperframes/core/figma"; const dirs: string[] = []; @@ -17,8 +17,9 @@ afterEach(() => { }); function fakeClient(overrides: Partial = {}): FigmaClient { - return { + const client: FigmaClient = { renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }), + renderNodes: () => Promise.resolve([]), imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), styles: () => Promise.resolve([]), @@ -26,6 +27,19 @@ function fakeClient(overrides: Partial = {}): FigmaClient { fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }), ...overrides, }; + // Default renderNodes delegates to renderNode (honoring any override) so + // existing single-node tests keep controlling behavior via renderNode. + if (!overrides.renderNodes) { + client.renderNodes = (fileKey, nodeIds, opts) => + Promise.all( + nodeIds.map((nodeId) => + client + .renderNode({ fileKey, nodeId }, opts) + .then((r) => ({ nodeId, url: r.url, ext: r.ext })), + ), + ); + } + return client; } const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -134,6 +148,39 @@ describe("runAssetImport", () => { expect(index).not.toContain("image_002"); }); + it("batches many nodes into ONE renderNodes call and freezes each", async () => { + const dir = scratch(); + let renderNodesCalls = 0; + let batchSize = 0; + const batchClient = fakeClient({ + renderNodes: (fileKey, nodeIds, opts) => { + renderNodesCalls += 1; + batchSize = nodeIds.length; + return Promise.resolve( + nodeIds.map((nodeId) => ({ nodeId, url: `https://cdn/${nodeId}`, ext: opts.format })), + ); + }, + }); + const results = await runAssetImportMany( + ["KEY:1-2", "KEY:3-4", "KEY:5-6"], + { format: "png" }, + deps(dir, { client: batchClient }), + ); + expect(results).toHaveLength(3); + expect(results.every((r) => !r.reused)).toBe(true); + expect(renderNodesCalls).toBe(1); // one REST call for all three + expect(batchSize).toBe(3); + // distinct frozen files, all recorded + expect(new Set(results.map((r) => r.record.id)).size).toBe(3); + }); + + it("splits comma-joined refs and rejects a cross-file batch", async () => { + const dir = scratch(); + await expect( + runAssetImportMany(["KEY:1-2", "OTHER:3-4"], { format: "png" }, deps(dir)), + ).rejects.toThrow(/share a fileKey/); + }); + it("reuses against ANY matching tuple, not just the oldest row", async () => { const dir = scratch(); await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg) diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 261dda459..9da7bc33b 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -9,6 +9,7 @@ import { appendRecord, buildAssetSnippet, createFigmaClient, + FigmaClientError, findAllByFigmaNode, freezeBytes, nextId, @@ -49,56 +50,68 @@ export interface AssetImportResult { reused: boolean; } -export async function runAssetImport( - refInput: string, - opts: AssetImportOptions, - deps: AssetImportDeps, -): Promise { +function requireNodeRef(refInput: string): { fileKey: string; nodeId: string } { const ref = parseFigmaRef(refInput); if (!ref.nodeId) throw new Error( `ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`, ); + return { fileKey: ref.fileKey, nodeId: ref.nodeId }; +} - const { version } = await deps.client.fileVersion(ref.fileKey); - const description = normalizeMeta(opts.description); - const entity = normalizeMeta(opts.entity); - - // Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse. - // Check EVERY row for the node (a node can legitimately have several - // format/scale/version tuples — the oldest-row shortcut minted duplicates - // forever once a second tuple existed). Unspecified scale is canonically 1 - // on both sides (figma's default). Reuse also requires the frozen file to - // still exist — a deleted file falls through to re-import. - const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find( +/** Cache hit per spec §5 (fileKey:nodeId:format:scale:version). Check EVERY + * row for the node — a node can carry several format/scale/version tuples, + * and the oldest-row shortcut minted duplicates forever. Reuse requires the + * frozen file to still exist; a deleted file falls through to re-import. + * Metadata supplied on a re-import upserts rather than being discarded. */ +function reuseExisting( + fileKey: string, + nodeId: string, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): AssetImportResult | null { + const existing = findAllByFigmaNode(deps.projectDir, fileKey, nodeId).find( (r) => r.provenance.format === opts.format && (r.provenance.scale ?? 1) === (opts.scale ?? 1) && r.provenance.version === version && existsSync(join(deps.projectDir, r.path)), ); - if (existing) { - // Metadata supplied on a re-import still lands: upsert the row instead - // of silently discarding the flags. - let record = existing; - if ( - (description !== undefined && description !== existing.description) || - (entity !== undefined && entity !== existing.entity) - ) { - record = { - ...existing, - ...(description !== undefined && { description }), - ...(entity !== undefined && { entity }), - }; - updateRecord(deps.projectDir, record); - } - safeRegenerateIndex(deps.projectDir); - return { record, snippet: buildAssetSnippet(record), reused: true }; + if (!existing) return null; + let record = existing; + if ( + (description !== undefined && description !== existing.description) || + (entity !== undefined && entity !== existing.entity) + ) { + record = { + ...existing, + ...(description !== undefined && { description }), + ...(entity !== undefined && { entity }), + }; + updateRecord(deps.projectDir, record); } + return { record, snippet: buildAssetSnippet(record), reused: true }; +} - const rendered = await deps.client.renderNode(ref, opts); - let bytes = await deps.download(rendered.url); - if (rendered.ext === "svg") { +/** Freeze a rendered node's bytes and record it. Does NOT regenerate index.md + * — the caller does that once (batch imports would otherwise rewrite it N + * times). */ +async function freezeAndRecord( + fileKey: string, + nodeId: string, + url: string, + ext: FigmaAssetFormat, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): Promise { + let bytes = await deps.download(url); + if (ext === "svg") { // Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A // non-text payload would decode to U+FFFD soup and still write to disk. const b0 = bytes[0]; @@ -106,32 +119,102 @@ export async function runAssetImport( throw new Error("figma render returned non-SVG bytes for an svg export — retry the import"); bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes))); } - const id = nextId(deps.projectDir, "image"); - const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${rendered.ext}`); + const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${ext}`); freezeBytes(bytes, destAbs); - const record: FigmaManifestRecord = { id, type: "image", path: relative(deps.projectDir, destAbs), - source: `figma:${ref.fileKey}/${ref.nodeId}`, + source: `figma:${fileKey}/${nodeId}`, ...(description !== undefined && { description }), ...(entity !== undefined && { entity }), provenance: { source: "figma", - fileKey: ref.fileKey, - nodeId: ref.nodeId, + fileKey, + nodeId, version, format: opts.format, scale: opts.scale, }, }; appendRecord(deps.projectDir, record); - safeRegenerateIndex(deps.projectDir); return { record, snippet: buildAssetSnippet(record), reused: false }; } +export async function runAssetImport( + refInput: string, + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + const [result] = await runAssetImportMany([refInput], opts, deps); + if (!result) throw new Error(`figma asset import produced no result for "${refInput}"`); + return result; +} + +/** + * Import many nodes of ONE figma file. Cache-checks each, renders the misses + * in a SINGLE /v1/images batch call (figma's documented rate-limit + * workaround — N nodes, one REST request), freezes each, and regenerates + * index.md once. Results come back in input order. + */ +export async function runAssetImportMany( + refInputs: string[], + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + if (refInputs.length === 0) return []; + const refs = refInputs.map(requireNodeRef); + const fileKey = refs[0]!.fileKey; + const mixed = refs.find((r) => r.fileKey !== fileKey); + if (mixed) + throw new Error( + `all refs in one import must share a fileKey (batch is per-file) — got ${fileKey} and ${mixed.fileKey}; run separate commands per file`, + ); + + const { version } = await deps.client.fileVersion(fileKey); + const description = normalizeMeta(opts.description); + const entity = normalizeMeta(opts.entity); + + // Resolve cache hits first; batch-render only the misses. + const slots: (AssetImportResult | null)[] = refs.map((r) => + reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity), + ); + const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : [])); + if (missIndexes.length > 0) { + const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId); + const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts); + const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const)); + for (const i of missIndexes) { + const nodeId = refs[i]!.nodeId; + const r = byNode.get(nodeId); + // Keep the typed code: component import's rasterize fallback skips on + // RENDER_FAILED, so a plain Error here would abort the whole import. + if (!r || r.url === null) + throw new FigmaClientError( + "RENDER_FAILED", + `figma could not render node ${nodeId} as ${opts.format}`, + ); + slots[i] = await freezeAndRecord( + fileKey, + nodeId, + r.url, + r.ext, + opts, + version, + deps, + description, + entity, + ); + } + } + safeRegenerateIndex(deps.projectDir); + return slots.map((s, i) => { + if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`); + return s; + }); +} + /** index.md is a single table row per record — newlines/tabs in a * description would corrupt the whole table. */ function normalizeMeta(value: string | undefined): string | undefined { @@ -159,11 +242,12 @@ function parseFormat(raw: string): FigmaAssetFormat { } export default defineCommand({ - meta: { name: "asset", description: "Import a figma node as a frozen local asset" }, + meta: { name: "asset", description: "Import one or more figma nodes as frozen local assets" }, args: { ref: { type: "positional", - description: "figma URL, fileKey:nodeId, or fileKey", + description: + "figma URL, fileKey:nodeId, or fileKey (pass several, or comma-separate ids, to batch)", required: true, }, format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" }, @@ -183,8 +267,18 @@ export default defineCommand({ const t0 = Date.now(); const token = process.env.FIGMA_TOKEN ?? ""; const client = createFigmaClient({ token }); - const result = await runAssetImport( - args.ref, + // citty puts ALL positionals in `args._` (including the one bound to the + // named `ref`), so use `_` as the source of truth — reading both would + // double-count the first. Split any comma-joined ids, so `asset A B`, + // `asset A,B`, and `asset URL1 URL2` all batch into ONE /v1/images call. + const positionals = + Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref]; + const refs = positionals + .flatMap((r) => String(r).split(",")) + .map((r) => r.trim()) + .filter((r) => r.length > 0); + const results = await runAssetImportMany( + refs, { format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, @@ -193,11 +287,18 @@ export default defineCommand({ }, { projectDir: args.dir, client, download: downloadRender }, ); - const verb = result.reused ? "reused" : "imported"; - console.log(`${verb} ${result.record.id} → ${result.record.path}`); - console.log(result.snippet.html); + for (const result of results) { + const verb = result.reused ? "reused" : "imported"; + console.log(`${verb} ${result.record.id} → ${result.record.path}`); + console.log(result.snippet.html); + } + if (results.length > 1) console.log(`(${results.length} nodes in 1 figma request)`); const { trackFigmaImport } = await import("../../telemetry/index.js"); - trackFigmaImport({ phase: "asset", reused: result.reused, durationMs: Date.now() - t0 }); + trackFigmaImport({ + phase: "asset", + reused: results.every((r) => r.reused), + durationMs: Date.now() - t0, + }); }); }, }); diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index 0f8a7ad5a..260f02aa1 100644 --- a/packages/cli/src/commands/figma/component.test.ts +++ b/packages/cli/src/commands/figma/component.test.ts @@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode(""); function client(): FigmaClient { return { renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }), + // Delegates to whatever renderNode is on the final object (via `this`), so + // inline clients that spread `...client()` and override renderNode still + // drive the batch path; rejections propagate (matching production). + renderNodes(fileKey, nodeIds, opts) { + return Promise.all( + nodeIds.map((nodeId) => + this.renderNode({ fileKey, nodeId }, opts).then((r) => ({ + nodeId, + url: r.url, + ext: r.ext, + })), + ), + ); + }, imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), styles: () => Promise.resolve([]), diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts index 0ba33827c..e76792ac3 100644 --- a/packages/cli/src/ui/format.ts +++ b/packages/cli/src/ui/format.ts @@ -46,7 +46,16 @@ export function label(name: string, value: string): string { export function errorBox(title: string, hint?: string, suggestion?: string): void { console.error(`\n${c.error("\u2717")} ${c.bold(title)}`); - if (hint) console.error(`\n ${c.dim(hint)}`); + if (hint) { + // Indent EVERY hint line, not just the first \u2014 a multi-line hint (e.g. the + // NO_TOKEN numbered setup list) otherwise had line 1 indented and the rest + // flush-left, mangling the list. Single-line hints are unchanged. + const indented = hint + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + console.error(`\n${c.dim(indented)}`); + } if (suggestion) console.error(` ${c.accent(suggestion)}`); console.error(); } diff --git a/packages/core/src/figma/client.test.ts b/packages/core/src/figma/client.test.ts index 3db4edb46..6bf5c7dd1 100644 --- a/packages/core/src/figma/client.test.ts +++ b/packages/core/src/figma/client.test.ts @@ -175,7 +175,7 @@ describe("error mapping", () => { expect(waits).toEqual([5000]); }); - it("names the library_content scope in the styles 403 message", async () => { + it("names the endpoint scope in the styles 403 when the body is silent", async () => { const client = createFigmaClient({ token: "t", fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch, @@ -188,6 +188,77 @@ describe("error mapping", () => { ); }); + it("surfaces figma's own scope diagnosis verbatim from the 403 body (err field)", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => + jsonResponse(403, { + err: "Invalid scope(s): file_content:read, file_metadata:read. This endpoint requires the library_content:read scope", + }), + ).fetch, + }); + await expect(client.styles("F")).rejects.toThrowError( + expect.objectContaining({ + code: "FORBIDDEN", + message: expect.stringContaining("requires the library_content:read scope"), + }), + ); + }); + + it("reclassifies a 403 'Invalid token' body as BAD_TOKEN, not a scope problem", async () => { + // figma returns 403 (not 401) for bad PATs on file endpoints — verified live + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(403, { err: "Invalid token" })).fetch, + }); + const err = await client.styles("F").catch((e: unknown) => e); + expect(err).toBeInstanceOf(FigmaClientError); + if (err instanceof FigmaClientError) { + expect(err.code).toBe("BAD_TOKEN"); + expect(err.message).toContain("Re-mint"); + } + }); + + it("keeps REQUIRES_ENTERPRISE for a scopeless variables 403", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch, + }); + await expect(client.variables("F")).rejects.toThrowError( + expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }), + ); + }); +}); + +describe("renderNodes (batch)", () => { + it("fetches many nodes in ONE /v1/images call and maps each url", async () => { + const stub = fetchStub(() => + jsonResponse(200, { + images: { "1:2": "https://cdn/a.png", "3:4": "https://cdn/b.png" }, + }), + ); + const client = createFigmaClient({ token: "t", fetch: stub.fetch }); + const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "png" }); + expect(stub.calls).toHaveLength(1); + expect(stub.calls[0]).toContain("ids=1%3A2%2C3%3A4"); // "1:2,3:4" url-encoded + expect(out).toEqual([ + { nodeId: "1:2", url: "https://cdn/a.png", ext: "png" }, + { nodeId: "3:4", url: "https://cdn/b.png", ext: "png" }, + ]); + }); + + it("returns url:null for a node figma couldn't render, without failing the batch", async () => { + const client = createFigmaClient({ + token: "t", + fetch: fetchStub(() => + jsonResponse(200, { images: { "1:2": "https://cdn/a.png", "3:4": null } }), + ).fetch, + }); + const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "svg" }); + expect(out[0]?.url).toBe("https://cdn/a.png"); + expect(out[1]?.url).toBeNull(); + }); + it("wraps other failures as HTTP_ERROR with status", async () => { const client = createFigmaClient({ token: "t", diff --git a/packages/core/src/figma/client.ts b/packages/core/src/figma/client.ts index c2761f933..35731ea63 100644 --- a/packages/core/src/figma/client.ts +++ b/packages/core/src/figma/client.ts @@ -76,8 +76,24 @@ export interface FigmaFileVersion { lastModified: string; } +/** One batch render result — url is null when figma couldn't render that + * node (a bad node id in the batch shouldn't fail the whole call). */ +export interface BatchRenderedNode { + nodeId: string; + url: string | null; + ext: FigmaAssetFormat; +} + export interface FigmaClient { renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise; + /** Batch render many nodes of ONE file in a single /v1/images call — the + * documented rate-limit workaround (comma-separated ids). Per-node + * failures come back as url:null rather than throwing the batch. */ + renderNodes( + fileKey: string, + nodeIds: string[], + opts: RenderNodeOptions, + ): Promise; imageFills(fileKey: string): Promise>; variables(fileKey: string): Promise; styles(fileKey: string): Promise; @@ -117,6 +133,29 @@ function retryAfterMs(res: Response): number | null { return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); } +/** Figma's error bodies are precise — "Invalid token", or "Invalid scope(s): + * … requires the X scope" — and worth surfacing verbatim instead of a + * generic guess. The message lives under `err` on most endpoints but + * `message` on /variables; read both. Returns null when unparseable. */ +async function readFigmaErrorMessage(res: Response): Promise { + let text: string; + try { + text = await res.text(); + } catch { + return null; + } + try { + const body: unknown = JSON.parse(text); + if (isRecord(body)) { + if (typeof body.err === "string") return body.err; + if (typeof body.message === "string") return body.message; + } + } catch { + // non-JSON body — fall through + } + return text.trim() === "" ? null : text.trim(); +} + function requireNodeId(ref: FigmaRef): string { if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`); return ref.nodeId; @@ -172,8 +211,42 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { scopeHint?: string; } + /** Map a 403 to the right typed error using figma's own response body: + * "Invalid token" is a bad PAT (figma returns 403, NOT 401, for these on + * file endpoints), "Invalid scope(s) … requires X" is a missing scope + * surfaced verbatim. Falls back to the endpoint's scopeHint when the body + * is silent. */ + function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError { + if (body && /invalid token/i.test(body)) + throw new FigmaClientError( + "BAD_TOKEN", + "figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", + 403, + ); + if (opts.enterpriseGated) + return new FigmaClientError( + "REQUIRES_ENTERPRISE", + "figma variables require an Enterprise plan (403) — fall back to styles", + 403, + ); + if (body && /scope/i.test(body)) + return new FigmaClientError( + "FORBIDDEN", + `figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`, + 403, + ); + const scopeLine = opts.scopeHint + ? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.` + : "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security."; + return new FigmaClientError( + "FORBIDDEN", + `figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`, + 403, + ); + } + /** Throw the typed error for a non-ok response (no-op when res.ok). */ - function throwForStatus(res: Response, path: string, opts: GetOptions): void { + async function throwForStatus(res: Response, path: string, opts: GetOptions): Promise { if (res.ok) return; if (res.status === 401) throw new FigmaClientError( @@ -181,22 +254,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { "figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.", 401, ); - if (res.status === 403 && opts.enterpriseGated) - throw new FigmaClientError( - "REQUIRES_ENTERPRISE", - "figma variables require an Enterprise plan (403) — fall back to styles", - 403, - ); - if (res.status === 403) { - const scopeLine = opts.scopeHint - ? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.` - : "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security."; - throw new FigmaClientError( - "FORBIDDEN", - `figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`, - 403, - ); - } + if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts); if (res.status === 429) throw new FigmaClientError( "RATE_LIMITED", @@ -221,26 +279,40 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient { const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt; await sleep(wait); } - throwForStatus(res, path, opts); + await throwForStatus(res, path, opts); return res.json(); } return { async renderNode(ref, opts) { const nodeId = requireNodeId(ref); - const params = new URLSearchParams({ ids: nodeId, format: opts.format }); - if (opts.scale !== undefined) params.set("scale", String(opts.scale)); - const body = await get(`/v1/images/${ref.fileKey}?${params}`, { - scopeHint: SCOPE_HINTS.fileContent, - }); - const images = isRecord(body) && isRecord(body.images) ? body.images : {}; - const url = images[nodeId]; - if (typeof url !== "string" || url === "") + const [result] = await this.renderNodes(ref.fileKey, [nodeId], opts); + if (!result || result.url === null) throw new FigmaClientError( "RENDER_FAILED", `figma could not render node ${nodeId} as ${opts.format}`, ); - return { url, ext: opts.format }; + return { url: result.url, ext: opts.format }; + }, + + async renderNodes(fileKey, nodeIds, opts) { + if (nodeIds.length === 0) return []; + // /v1/images accepts comma-separated ids — one call for the whole batch, + // which is figma's own answer to the per-minute rate limit. + const params = new URLSearchParams({ ids: nodeIds.join(","), format: opts.format }); + if (opts.scale !== undefined) params.set("scale", String(opts.scale)); + const body = await get(`/v1/images/${fileKey}?${params}`, { + scopeHint: SCOPE_HINTS.fileContent, + }); + const images = isRecord(body) && isRecord(body.images) ? body.images : {}; + return nodeIds.map((nodeId) => { + const url = images[nodeId]; + return { + nodeId, + url: typeof url === "string" && url !== "" ? url : null, + ext: opts.format, + }; + }); }, async imageFills(fileKey) { diff --git a/skills-manifest.json b/skills-manifest.json index 78035f0cf..427739bcd 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 18 }, "figma": { - "hash": "903f643bffefa5ce", + "hash": "5581f824cc5deecc", "files": 2 }, "general-video": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 081a8bb29..e5c7e1462 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -51,11 +51,13 @@ Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `f ## Assets (Phase 1 — CLI) ```bash -hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."] +hyperframes figma asset '' [more refs…] [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."] ``` Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, regenerates `.media/index.md` (the shared media-use inventory), prints an `` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description ""`** (it becomes the index row + ``); add `--entity ""` for named brand marks so media-use `resolve --entity` finds them later (entity hits match across image/icon). +**Batch many nodes in ONE request** — pass several refs (space-separated or comma-joined) of the SAME file: `hyperframes figma asset 'KEY:1-2' 'KEY:3-4' 'KEY:5-6'`. All render in a single `/v1/images` call, which is figma's own answer to the per-minute rate limit — prefer it over N separate commands when pulling a whole frame's worth of assets. `--description`/`--entity` apply to every node in the batch, so batch nodes that share a purpose. 429s also auto-retry with backoff regardless. + ## Tokens (Phase 2 — CLI) ```bash