diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx
index c10dc4fdfd..6d180861ea 100644
--- a/docs/guides/figma.mdx
+++ b/docs/guides/figma.mdx
@@ -10,7 +10,7 @@ The work your designer already did in Figma — layout, color, type, motion —
| Capability | What you get | Surface |
| --- | --- | --- |
| **Static assets** | A frame/layer rendered to SVG/PNG/JPG/PDF, frozen under `.media/` | `hyperframes figma asset` |
-| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` |
+| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` (Enterprise) or the `/figma` skill via MCP (any plan) |
| **Components** | A frame as editable HTML with brand-linked colors | `hyperframes figma component` |
| **Motion** | A Figma Motion timeline as an editable, paused GSAP timeline | `/figma` skill (agent, MCP) |
| **Shaders** | A shader fill/effect as a frozen still or clip | `/figma` skill (agent, MCP) |
@@ -20,22 +20,32 @@ Two transports, split by what Figma exposes: assets, tokens, and components run
## One-time setup
-The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environment variable.
+There are two credentials, and most people only need to set up one to start:
+
+| You want to import… | Set up |
+| --- | --- |
+| A logo, image, or a whole frame as HTML (assets, components) | A **token** — Step A below |
+| Brand colors (tokens) | Either works, but on a non-Enterprise plan the **MCP connector** (Step B) gets you there in one click — the token path needs an Enterprise plan for this specific pull |
+| Motion, shaders, or a storyboard | The **MCP connector** only — no token, no setup beyond connecting it |
+
+Do both if your project needs everything; each is independent, so it doesn't matter which you set up first.
+
+### Step A — Figma token (assets, tokens, components)
+
+Needed for anything you run from the `hyperframes figma` CLI.
In Figma: **Settings → Security → Personal access tokens → Generate new token.**
-
- The integration never writes to Figma — read-only is all it ever needs:
+
+ Read-only is all it ever needs — the integration never writes to Figma. **On most accounts (not Figma Enterprise), check exactly these three:**
- | Scope | Setting | Needed for |
- | --- | --- | --- |
- | File content | Read-only | assets, components |
- | File metadata | Read-only | version tracking, refresh |
- | Variables | Read-only | brand variables — **Figma Enterprise only** |
+ - **File content** — Read-only
+ - **File metadata** — Read-only
+ - **Library content** — Read-only — easy to miss, and without it `tokens` 403s the moment it tries the published-styles fallback
- No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error.
+ On a **Figma Enterprise** plan, also check **Variables — Read-only** to pull brand colors directly via `tokens`. Not on Enterprise? Skip it — `tokens` falls back to published styles automatically, or use the MCP connector (Step B) instead, which reaches variables on any plan.
```bash
@@ -46,7 +56,11 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen
-Motion and shader import use the **Figma MCP connector** instead — a one-click OAuth from your agent, separate from the token. Connect it when your agent asks; no scopes to configure.
+### Step B — Figma MCP connector (motion, shaders, storyboards — and an easier token-free path to brand colors)
+
+No token, no scopes to pick — connect it once when your agent asks (a one-click OAuth) and it stays connected.
+
+This is also the easiest way to pull brand colors on **any** Figma plan, including free: the connector's variable-reading tool isn't Enterprise-gated, only rate-limited by plan — Starter/free caps at **6 calls/month**, a paid Full/Dev seat gets 200–600/day. Fine for an occasional brand pull; not for iterating call-by-call.
## Import an asset
@@ -109,9 +123,9 @@ Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.me
| Error | Meaning | Fix |
| --- | --- | --- |
| `NO_TOKEN` | `FIGMA_TOKEN` unset | Follow [One-time setup](#one-time-setup) |
-| `BAD_TOKEN` (401) | Token expired or revoked | Re-mint the token |
-| `FORBIDDEN` (403) | Token missing a read scope, or no access to the file | Check the read-only scopes above and file visibility |
-| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — the styles fallback already ran |
-| `RATE_LIMITED` (429) | REST per-minute budget hit | Wait a minute and retry; chunk batch renders |
+| `BAD_TOKEN` | Token invalid, expired, or revoked (Figma returns **403 `Invalid token`** for bad PATs, not 401) | Re-mint the token |
+| `FORBIDDEN` (403) | Missing a read scope, or no access to the file | The message names the exact scope Figma wants (e.g. `library_content:read` for the styles fallback) — add it, or check file visibility |
+| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — `tokens` falls back to published styles (which needs the Library content scope above) |
+| `RATE_LIMITED` (429) | Figma's per-minute limit | The client retries with backoff automatically (honoring `Retry-After`); if it still surfaces, wait a minute or batch fewer nodes |
| "Render timeout" on batch export | Too many large frames in one `/v1/images` call | Chunk to ~4 ids per call |
| `ref has no node id` | Link points at a file, not a node | Copy the link with `?node-id=…` (right-click layer → Copy link) |
diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts
index c3e25a340b..6e0e1d44de 100644
--- a/packages/cli/src/commands/figma/asset.test.ts
+++ b/packages/cli/src/commands/figma/asset.test.ts
@@ -3,7 +3,12 @@ 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 {
+ gatherAssetRefs,
+ runAssetImport,
+ runAssetImportMany,
+ type AssetImportDeps,
+} from "./asset.js";
import type { FigmaClient } from "@hyperframes/core/figma";
const dirs: string[] = [];
@@ -17,8 +22,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 +32,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 +153,51 @@ 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("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => {
+ // bare tokens comma-split
+ expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
+ // space-separated positionals preserved
+ expect(gatherAssetRefs(["KEY:1-2", "KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]);
+ // a URL with a comma in its query is NOT torn apart
+ const url = "https://www.figma.com/design/KEY/F?node-id=1:2,3:4";
+ expect(gatherAssetRefs([url])).toEqual([url]);
+ // mixed: URL stays whole, bare token splits
+ expect(gatherAssetRefs([url, "KEY:5-6,KEY:7-8"])).toEqual([url, "KEY:5-6", "KEY:7-8"]);
+ });
+
+ 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 261dda4598..d1a307ea37 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,82 @@ export interface AssetImportResult {
reused: boolean;
}
-export async function runAssetImport(
- refInput: string,
- opts: AssetImportOptions,
- deps: AssetImportDeps,
-): Promise {
+/**
+ * Flatten CLI positionals into asset refs. Comma-splits bare
+ * `fileKey:nodeId` tokens (so `asset A,B` batches) but leaves URL tokens
+ * whole — a figma URL can carry commas in its query (multi-select
+ * `node-id=1:2,3:4`), and splitting those would tear the URL apart. To batch
+ * URLs, pass them as separate positional args.
+ */
+export function gatherAssetRefs(positionals: string[]): string[] {
+ return positionals
+ .flatMap((r) => (/^https?:/i.test(r.trim()) ? [r] : r.split(",")))
+ .map((r) => r.trim())
+ .filter((r) => r.length > 0);
+}
+
+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 +133,108 @@ 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] : []));
+ try {
+ 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,
+ );
+ }
+ }
+ } finally {
+ // Regenerate once — in `finally` so a mid-batch RENDER_FAILED still leaves
+ // index.md consistent with the nodes that DID freeze, not stale until the
+ // next import.
+ 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 +262,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 +287,16 @@ 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]
+ ).map(String);
+ const refs = gatherAssetRefs(positionals);
+ const results = await runAssetImportMany(
+ refs,
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
@@ -193,11 +305,25 @@ 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) {
+ const rendered = results.filter((r) => !r.reused).length;
+ console.log(
+ rendered > 0
+ ? `(${results.length} nodes, ${rendered} rendered in 1 figma request)`
+ : `(${results.length} nodes, all reused from cache — no 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 0f8a7ad5a5..260f02aa1d 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/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts
index 760d4d89cc..8f7f1c6e9e 100644
--- a/packages/cli/src/commands/figma/tokens.test.ts
+++ b/packages/cli/src/commands/figma/tokens.test.ts
@@ -15,6 +15,7 @@ afterEach(() => rmSync(dir, { recursive: true, force: true }));
function client(overrides: Partial): FigmaClient {
return {
renderNode: () => Promise.reject(new Error("unused")),
+ renderNodes: () => Promise.reject(new Error("unused")),
imageFills: () => Promise.resolve(new Map()),
variables: () =>
Promise.resolve({
@@ -56,12 +57,24 @@ describe("runTokensImport", () => {
const out = await runTokensImport("FILE", { projectDir: dir, client: gated });
expect(out.mode).toBe("styles");
expect(out.entries).toEqual([]);
+ expect(out.styleCount).toBe(1);
const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as {
tokens: Array<{ name: string; type: string }>;
};
expect(sidecar.tokens[0]).toMatchObject({ name: "Primary", type: "style:FILL" });
});
+ it("reports styleCount 0 when the file has no published styles — never a false success", async () => {
+ const gatedNoStyles = client({
+ variables: () =>
+ Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)),
+ styles: () => Promise.resolve([]),
+ });
+ const out = await runTokensImport("FILE", { projectDir: dir, client: gatedNoStyles });
+ expect(out.mode).toBe("styles");
+ expect(out.styleCount).toBe(0);
+ });
+
it("propagates non-enterprise failures", async () => {
const broken = client({
variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)),
diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts
index a1cc6a69e6..87ac8a8d99 100644
--- a/packages/cli/src/commands/figma/tokens.ts
+++ b/packages/cli/src/commands/figma/tokens.ts
@@ -30,6 +30,10 @@ export interface TokensImportResult {
mode: "variables" | "styles";
entries: CompositionVariableEntry[];
sidecarPath: string;
+ /** styles mode only: how many published styles were actually found —
+ * entries is always [] in this mode (style values resolve later, at
+ * component-import time), so this is what tells success from empty. */
+ styleCount?: number;
}
export async function runTokensImport(
@@ -68,7 +72,7 @@ export async function runTokensImport(
})),
};
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n");
- return { mode: "styles", entries: [], sidecarPath };
+ return { mode: "styles", entries: [], sidecarPath, styleCount: styles.length };
}
export default defineCommand({
@@ -84,7 +88,9 @@ export default defineCommand({
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
if (result.mode === "styles") {
console.log(
- "variables are Enterprise-gated on this plan — recorded published style metadata instead (style values resolve at component-import time)",
+ (result.styleCount ?? 0) > 0
+ ? `variables are Enterprise-gated on this plan — recorded ${result.styleCount} published style(s) instead (style values resolve at component-import time)`
+ : "variables are Enterprise-gated on this plan, and this file has no published library styles to fall back to — nothing recorded. Publish the file's styles to a team library, or read variables via the Figma MCP connector's get_variable_defs instead (works on any plan, rate-limited).",
);
}
console.log(`wrote ${result.sidecarPath} (${result.mode})`);
diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts
index 0ba33827ca..e76792ac33 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 04c3b14005..3af01728a6 100644
--- a/packages/core/src/figma/client.test.ts
+++ b/packages/core/src/figma/client.test.ts
@@ -106,14 +106,18 @@ describe("variables", () => {
});
describe("error mapping", () => {
- it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => {
+ it("maps 429 to RATE_LIMITED (after retries) and 401 to BAD_TOKEN", async () => {
+ const stub = fetchStub(() => jsonResponse(429, {}));
const c429 = createFigmaClient({
token: "t",
- fetch: fetchStub(() => jsonResponse(429, {})).fetch,
+ fetch: stub.fetch,
+ sleep: () => Promise.resolve(),
});
await expect(c429.styles("F")).rejects.toThrowError(
expect.objectContaining({ code: "RATE_LIMITED" }),
);
+ // 1 initial + 3 retries = 4 attempts
+ expect(stub.calls).toHaveLength(4);
const c401 = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(401, {})).fetch,
@@ -123,6 +127,179 @@ describe("error mapping", () => {
);
});
+ it("retries 429 and succeeds when the limit clears", async () => {
+ let n = 0;
+ const waits: number[] = [];
+ const client = createFigmaClient({
+ token: "t",
+ fetch: (() => {
+ n += 1;
+ return Promise.resolve(
+ n < 3
+ ? jsonResponse(429, {})
+ : jsonResponse(200, {
+ meta: { styles: [{ key: "k", name: "P", style_type: "FILL" }] },
+ }),
+ );
+ }) as FigmaFetch,
+ sleep: (ms) => {
+ waits.push(ms);
+ return Promise.resolve();
+ },
+ });
+ const styles = await client.styles("F");
+ expect(styles[0]?.key).toBe("k");
+ expect(n).toBe(3); // two 429s then success
+ expect(waits).toEqual([1000, 2000]); // exponential backoff
+ });
+
+ it("caps an oversized Retry-After at 60s so the CLI can't block for an hour", async () => {
+ let n = 0;
+ const waits: number[] = [];
+ const client = createFigmaClient({
+ token: "t",
+ fetch: (() => {
+ n += 1;
+ return Promise.resolve(
+ n === 1
+ ? new Response("{}", { status: 429, headers: { "retry-after": "3600" } })
+ : jsonResponse(200, { meta: { styles: [] } }),
+ );
+ }) as FigmaFetch,
+ sleep: (ms) => {
+ waits.push(ms);
+ return Promise.resolve();
+ },
+ });
+ await client.styles("F");
+ expect(waits).toEqual([60_000]); // 3600s clamped, not 3_600_000
+ });
+
+ it("retries 429 on non-styles endpoints too (retry lives in the shared get)", async () => {
+ let n = 0;
+ const client = createFigmaClient({
+ token: "t",
+ fetch: (() => {
+ n += 1;
+ return Promise.resolve(
+ n < 2
+ ? jsonResponse(429, {})
+ : jsonResponse(200, { images: { "1:2": "https://cdn/a.png" } }),
+ );
+ }) as FigmaFetch,
+ sleep: () => Promise.resolve(),
+ });
+ const out = await client.renderNodes("F", ["1:2"], { format: "png" });
+ expect(out[0]?.url).toBe("https://cdn/a.png");
+ expect(n).toBe(2); // one 429 then success
+ });
+
+ it("honors Retry-After (seconds) over the backoff default", async () => {
+ let n = 0;
+ const waits: number[] = [];
+ const client = createFigmaClient({
+ token: "t",
+ fetch: (() => {
+ n += 1;
+ return Promise.resolve(
+ n === 1
+ ? new Response("{}", { status: 429, headers: { "retry-after": "5" } })
+ : jsonResponse(200, { meta: { styles: [] } }),
+ );
+ }) as FigmaFetch,
+ sleep: (ms) => {
+ waits.push(ms);
+ return Promise.resolve();
+ },
+ });
+ await client.styles("F");
+ expect(waits).toEqual([5000]);
+ });
+
+ 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,
+ });
+ await expect(client.styles("F")).rejects.toThrowError(
+ expect.objectContaining({
+ code: "FORBIDDEN",
+ message: expect.stringContaining("library_content:read"),
+ }),
+ );
+ });
+
+ 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 efe8f51903..29efadd6dc 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