mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2112 from heygen-com/vi/figma-scopes-retry
fix(figma): auth/retry/batch hardening, mapper fidelity, skill routing, setup docs
This commit is contained in:
@@ -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> = {}): 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> = {}): 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)
|
||||
|
||||
@@ -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<AssetImportResult> {
|
||||
/**
|
||||
* 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<AssetImportResult> {
|
||||
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<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] : []));
|
||||
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,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode("<svg/>");
|
||||
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([]),
|
||||
|
||||
@@ -15,6 +15,7 @@ afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
function client(overrides: Partial<FigmaClient>): 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)),
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user