mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(core,cli): parse figma 403 body, batch asset fetch, fix NO_TOKEN box
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bb7688347
commit
4fc699fee6
@@ -3,7 +3,7 @@ import { describe, expect, it, afterEach } from "vitest";
|
|||||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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";
|
import type { FigmaClient } from "@hyperframes/core/figma";
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
@@ -17,8 +17,9 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
|
function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
|
||||||
return {
|
const client: FigmaClient = {
|
||||||
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }),
|
renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }),
|
||||||
|
renderNodes: () => Promise.resolve([]),
|
||||||
imageFills: () => Promise.resolve(new Map()),
|
imageFills: () => Promise.resolve(new Map()),
|
||||||
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
|
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
|
||||||
styles: () => Promise.resolve([]),
|
styles: () => Promise.resolve([]),
|
||||||
@@ -26,6 +27,19 @@ function fakeClient(overrides: Partial<FigmaClient> = {}): FigmaClient {
|
|||||||
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
|
fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }),
|
||||||
...overrides,
|
...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]);
|
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||||
@@ -134,6 +148,39 @@ describe("runAssetImport", () => {
|
|||||||
expect(index).not.toContain("image_002");
|
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 () => {
|
it("reuses against ANY matching tuple, not just the oldest row", async () => {
|
||||||
const dir = scratch();
|
const dir = scratch();
|
||||||
await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
|
await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
appendRecord,
|
appendRecord,
|
||||||
buildAssetSnippet,
|
buildAssetSnippet,
|
||||||
createFigmaClient,
|
createFigmaClient,
|
||||||
|
FigmaClientError,
|
||||||
findAllByFigmaNode,
|
findAllByFigmaNode,
|
||||||
freezeBytes,
|
freezeBytes,
|
||||||
nextId,
|
nextId,
|
||||||
@@ -49,56 +50,68 @@ export interface AssetImportResult {
|
|||||||
reused: boolean;
|
reused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAssetImport(
|
function requireNodeRef(refInput: string): { fileKey: string; nodeId: string } {
|
||||||
refInput: string,
|
|
||||||
opts: AssetImportOptions,
|
|
||||||
deps: AssetImportDeps,
|
|
||||||
): Promise<AssetImportResult> {
|
|
||||||
const ref = parseFigmaRef(refInput);
|
const ref = parseFigmaRef(refInput);
|
||||||
if (!ref.nodeId)
|
if (!ref.nodeId)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`,
|
`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);
|
/** Cache hit per spec §5 (fileKey:nodeId:format:scale:version). Check EVERY
|
||||||
const description = normalizeMeta(opts.description);
|
* row for the node — a node can carry several format/scale/version tuples,
|
||||||
const entity = normalizeMeta(opts.entity);
|
* and the oldest-row shortcut minted duplicates forever. Reuse requires the
|
||||||
|
* frozen file to still exist; a deleted file falls through to re-import.
|
||||||
// Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse.
|
* Metadata supplied on a re-import upserts rather than being discarded. */
|
||||||
// Check EVERY row for the node (a node can legitimately have several
|
function reuseExisting(
|
||||||
// format/scale/version tuples — the oldest-row shortcut minted duplicates
|
fileKey: string,
|
||||||
// forever once a second tuple existed). Unspecified scale is canonically 1
|
nodeId: string,
|
||||||
// on both sides (figma's default). Reuse also requires the frozen file to
|
opts: AssetImportOptions,
|
||||||
// still exist — a deleted file falls through to re-import.
|
version: string,
|
||||||
const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find(
|
deps: AssetImportDeps,
|
||||||
|
description: string | undefined,
|
||||||
|
entity: string | undefined,
|
||||||
|
): AssetImportResult | null {
|
||||||
|
const existing = findAllByFigmaNode(deps.projectDir, fileKey, nodeId).find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.provenance.format === opts.format &&
|
r.provenance.format === opts.format &&
|
||||||
(r.provenance.scale ?? 1) === (opts.scale ?? 1) &&
|
(r.provenance.scale ?? 1) === (opts.scale ?? 1) &&
|
||||||
r.provenance.version === version &&
|
r.provenance.version === version &&
|
||||||
existsSync(join(deps.projectDir, r.path)),
|
existsSync(join(deps.projectDir, r.path)),
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (!existing) return null;
|
||||||
// Metadata supplied on a re-import still lands: upsert the row instead
|
let record = existing;
|
||||||
// of silently discarding the flags.
|
if (
|
||||||
let record = existing;
|
(description !== undefined && description !== existing.description) ||
|
||||||
if (
|
(entity !== undefined && entity !== existing.entity)
|
||||||
(description !== undefined && description !== existing.description) ||
|
) {
|
||||||
(entity !== undefined && entity !== existing.entity)
|
record = {
|
||||||
) {
|
...existing,
|
||||||
record = {
|
...(description !== undefined && { description }),
|
||||||
...existing,
|
...(entity !== undefined && { entity }),
|
||||||
...(description !== undefined && { description }),
|
};
|
||||||
...(entity !== undefined && { entity }),
|
updateRecord(deps.projectDir, record);
|
||||||
};
|
|
||||||
updateRecord(deps.projectDir, record);
|
|
||||||
}
|
|
||||||
safeRegenerateIndex(deps.projectDir);
|
|
||||||
return { record, snippet: buildAssetSnippet(record), reused: true };
|
|
||||||
}
|
}
|
||||||
|
return { record, snippet: buildAssetSnippet(record), reused: true };
|
||||||
|
}
|
||||||
|
|
||||||
const rendered = await deps.client.renderNode(ref, opts);
|
/** Freeze a rendered node's bytes and record it. Does NOT regenerate index.md
|
||||||
let bytes = await deps.download(rendered.url);
|
* — the caller does that once (batch imports would otherwise rewrite it N
|
||||||
if (rendered.ext === "svg") {
|
* 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<AssetImportResult> {
|
||||||
|
let bytes = await deps.download(url);
|
||||||
|
if (ext === "svg") {
|
||||||
// Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A
|
// 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.
|
// non-text payload would decode to U+FFFD soup and still write to disk.
|
||||||
const b0 = bytes[0];
|
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");
|
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)));
|
bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = nextId(deps.projectDir, "image");
|
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);
|
freezeBytes(bytes, destAbs);
|
||||||
|
|
||||||
const record: FigmaManifestRecord = {
|
const record: FigmaManifestRecord = {
|
||||||
id,
|
id,
|
||||||
type: "image",
|
type: "image",
|
||||||
path: relative(deps.projectDir, destAbs),
|
path: relative(deps.projectDir, destAbs),
|
||||||
source: `figma:${ref.fileKey}/${ref.nodeId}`,
|
source: `figma:${fileKey}/${nodeId}`,
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
...(entity !== undefined && { entity }),
|
...(entity !== undefined && { entity }),
|
||||||
provenance: {
|
provenance: {
|
||||||
source: "figma",
|
source: "figma",
|
||||||
fileKey: ref.fileKey,
|
fileKey,
|
||||||
nodeId: ref.nodeId,
|
nodeId,
|
||||||
version,
|
version,
|
||||||
format: opts.format,
|
format: opts.format,
|
||||||
scale: opts.scale,
|
scale: opts.scale,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
appendRecord(deps.projectDir, record);
|
appendRecord(deps.projectDir, record);
|
||||||
safeRegenerateIndex(deps.projectDir);
|
|
||||||
return { record, snippet: buildAssetSnippet(record), reused: false };
|
return { record, snippet: buildAssetSnippet(record), reused: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runAssetImport(
|
||||||
|
refInput: string,
|
||||||
|
opts: AssetImportOptions,
|
||||||
|
deps: AssetImportDeps,
|
||||||
|
): Promise<AssetImportResult> {
|
||||||
|
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<AssetImportResult[]> {
|
||||||
|
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
|
/** index.md is a single table row per record — newlines/tabs in a
|
||||||
* description would corrupt the whole table. */
|
* description would corrupt the whole table. */
|
||||||
function normalizeMeta(value: string | undefined): string | undefined {
|
function normalizeMeta(value: string | undefined): string | undefined {
|
||||||
@@ -159,11 +242,12 @@ function parseFormat(raw: string): FigmaAssetFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineCommand({
|
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: {
|
args: {
|
||||||
ref: {
|
ref: {
|
||||||
type: "positional",
|
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,
|
required: true,
|
||||||
},
|
},
|
||||||
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
|
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
|
||||||
@@ -183,8 +267,18 @@ export default defineCommand({
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const token = process.env.FIGMA_TOKEN ?? "";
|
const token = process.env.FIGMA_TOKEN ?? "";
|
||||||
const client = createFigmaClient({ token });
|
const client = createFigmaClient({ token });
|
||||||
const result = await runAssetImport(
|
// citty puts ALL positionals in `args._` (including the one bound to the
|
||||||
args.ref,
|
// 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),
|
format: parseFormat(args.format),
|
||||||
scale: args.scale !== undefined ? Number(args.scale) : undefined,
|
scale: args.scale !== undefined ? Number(args.scale) : undefined,
|
||||||
@@ -193,11 +287,18 @@ export default defineCommand({
|
|||||||
},
|
},
|
||||||
{ projectDir: args.dir, client, download: downloadRender },
|
{ projectDir: args.dir, client, download: downloadRender },
|
||||||
);
|
);
|
||||||
const verb = result.reused ? "reused" : "imported";
|
for (const result of results) {
|
||||||
console.log(`${verb} ${result.record.id} → ${result.record.path}`);
|
const verb = result.reused ? "reused" : "imported";
|
||||||
console.log(result.snippet.html);
|
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");
|
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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode("<svg/>");
|
|||||||
function client(): FigmaClient {
|
function client(): FigmaClient {
|
||||||
return {
|
return {
|
||||||
renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }),
|
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()),
|
imageFills: () => Promise.resolve(new Map()),
|
||||||
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
|
variables: () => Promise.resolve({ variables: {}, variableCollections: {} }),
|
||||||
styles: () => Promise.resolve([]),
|
styles: () => Promise.resolve([]),
|
||||||
|
|||||||
@@ -46,7 +46,16 @@ export function label(name: string, value: string): string {
|
|||||||
|
|
||||||
export function errorBox(title: string, hint?: string, suggestion?: string): void {
|
export function errorBox(title: string, hint?: string, suggestion?: string): void {
|
||||||
console.error(`\n${c.error("\u2717")} ${c.bold(title)}`);
|
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)}`);
|
if (suggestion) console.error(` ${c.accent(suggestion)}`);
|
||||||
console.error();
|
console.error();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe("error mapping", () => {
|
|||||||
expect(waits).toEqual([5000]);
|
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({
|
const client = createFigmaClient({
|
||||||
token: "t",
|
token: "t",
|
||||||
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
|
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 () => {
|
it("wraps other failures as HTTP_ERROR with status", async () => {
|
||||||
const client = createFigmaClient({
|
const client = createFigmaClient({
|
||||||
token: "t",
|
token: "t",
|
||||||
|
|||||||
@@ -76,8 +76,24 @@ export interface FigmaFileVersion {
|
|||||||
lastModified: string;
|
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 {
|
export interface FigmaClient {
|
||||||
renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise<RenderedNode>;
|
renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise<RenderedNode>;
|
||||||
|
/** 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<BatchRenderedNode[]>;
|
||||||
imageFills(fileKey: string): Promise<Map<string, string>>;
|
imageFills(fileKey: string): Promise<Map<string, string>>;
|
||||||
variables(fileKey: string): Promise<FigmaVariablesResult>;
|
variables(fileKey: string): Promise<FigmaVariablesResult>;
|
||||||
styles(fileKey: string): Promise<FigmaStyleMeta[]>;
|
styles(fileKey: string): Promise<FigmaStyleMeta[]>;
|
||||||
@@ -117,6 +133,29 @@ function retryAfterMs(res: Response): number | null {
|
|||||||
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
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<string | null> {
|
||||||
|
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 {
|
function requireNodeId(ref: FigmaRef): string {
|
||||||
if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`);
|
if (!ref.nodeId) throw new Error(`figma ref ${ref.fileKey} has no nodeId`);
|
||||||
return ref.nodeId;
|
return ref.nodeId;
|
||||||
@@ -172,8 +211,42 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
|||||||
scopeHint?: string;
|
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). */
|
/** 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<void> {
|
||||||
if (res.ok) return;
|
if (res.ok) return;
|
||||||
if (res.status === 401)
|
if (res.status === 401)
|
||||||
throw new FigmaClientError(
|
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.",
|
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
if (res.status === 403 && opts.enterpriseGated)
|
if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts);
|
||||||
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 === 429)
|
if (res.status === 429)
|
||||||
throw new FigmaClientError(
|
throw new FigmaClientError(
|
||||||
"RATE_LIMITED",
|
"RATE_LIMITED",
|
||||||
@@ -221,26 +279,40 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
|||||||
const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt;
|
const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt;
|
||||||
await sleep(wait);
|
await sleep(wait);
|
||||||
}
|
}
|
||||||
throwForStatus(res, path, opts);
|
await throwForStatus(res, path, opts);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async renderNode(ref, opts) {
|
async renderNode(ref, opts) {
|
||||||
const nodeId = requireNodeId(ref);
|
const nodeId = requireNodeId(ref);
|
||||||
const params = new URLSearchParams({ ids: nodeId, format: opts.format });
|
const [result] = await this.renderNodes(ref.fileKey, [nodeId], opts);
|
||||||
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
|
if (!result || result.url === null)
|
||||||
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 === "")
|
|
||||||
throw new FigmaClientError(
|
throw new FigmaClientError(
|
||||||
"RENDER_FAILED",
|
"RENDER_FAILED",
|
||||||
`figma could not render node ${nodeId} as ${opts.format}`,
|
`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) {
|
async imageFills(fileKey) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
"hash": "903f643bffefa5ce",
|
"hash": "5581f824cc5deecc",
|
||||||
"files": 2
|
"files": 2
|
||||||
},
|
},
|
||||||
"general-video": {
|
"general-video": {
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `f
|
|||||||
## Assets (Phase 1 — CLI)
|
## Assets (Phase 1 — CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hyperframes figma asset '<url-or-fileKey:nodeId>' [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."]
|
hyperframes figma asset '<url-or-fileKey:nodeId>' [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 `<img>` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description "<what it is>"`** (it becomes the index row + `<img alt>`); add `--entity "<name>"` for named brand marks so media-use `resolve --entity` finds them later (entity hits match across image/icon).
|
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 `<img>` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description "<what it is>"`** (it becomes the index row + `<img alt>`); add `--entity "<name>"` 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)
|
## Tokens (Phase 2 — CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user