Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 2.2.4 (2026-09-26)

### Fixed

- After an update the old background daemon kept serving `render-batch` and `eval` with the previous version's code until it idled out. The daemon now reports its version and figma-cli restarts it when it does not match
- `render '<Instance component="Card" />'` and `render-batch` with instances place the instances directly on the page instead of failing with "must start with <Frame>"

### Changed

- The agent rules and the `eval` output limit point to `figma-cli verify <id> --scale 2` for a sharper screenshot

## 2.2.3 (2026-09-26)

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "figma-ds-cli",
"version": "2.2.3",
"version": "2.2.4",
"description": "CLI for managing Figma design systems. Create variables, components, and more. No API key required.",
"author": {
"name": "Sil Bormüller",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/export-eval.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ function formatResult(result) {
: process.stdout.isTTY ? JSON.stringify(result, null, 2) : JSON.stringify(result);
if (process.stdout.isTTY || text.length <= PIPED_OUTPUT_CAP) return text;
return text.slice(0, PIPED_OUTPUT_CAP) +
`\n… output cut at ${PIPED_OUTPUT_CAP} of ${text.length} characters. Return counts, a filtered list or .slice(0, N) instead.`;
`\n… output cut at ${PIPED_OUTPUT_CAP} of ${text.length} characters. Return counts, a filtered list or .slice(0, N) instead. For an image of a node use figma-cli verify <id> --scale 2 (saves a PNG).`;
}

// $page / $var / $bind / $instance / ... live on globalThis in Figma. Install
Expand Down
8 changes: 8 additions & 0 deletions src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { homedir, tmpdir } from 'os';
import { fileURLToPath, pathToFileURL } from 'url';
import { wrapCodeIfNeeded } from './lib/eval-wrap.js';

const DAEMON_ROOT = dirname(fileURLToPath(import.meta.url));
const DAEMON_VERSION = (() => { try { return JSON.parse(readFileSync(join(DAEMON_ROOT, '..', 'package.json'), 'utf8')).version; } catch { return null; } })();

// Hot-reload FigmaClient: copy to temp file and import (Node.js ES modules don't support cache busting)
const __dirname = dirname(fileURLToPath(import.meta.url));
const figmaClientPath = join(__dirname, 'figma-client.js');
Expand Down Expand Up @@ -360,6 +363,11 @@ async function handleRequest(req, res) {
// FIGMA_FILE and rebinds when they diverge — otherwise commands silently
// hit whichever file happened to be first when the daemon started.
file: (cdpClient && cdpClient.pageTitle) || null,
// Which figma-cli code this daemon runs. After an update the old daemon
// would keep serving render-batch and eval with old code until it idles
// out; the CLI restarts it when these do not match its own.
version: DAEMON_VERSION,
root: DAEMON_ROOT,
idleTimeoutMs: IDLE_TIMEOUT_MS
}));
return;
Expand Down
33 changes: 28 additions & 5 deletions src/figma-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import WebSocket from 'ws';
import { getCdpPort } from './figma-patch.js';
import { HELPERS_SOURCE, RESET_NOTES } from './lib/figma-helpers.js';

// Temporary wrapper for a root <Instance>: rendered like a frame, then the
// instance takes its place on the canvas and the wrapper is removed.
const UNWRAP_NAME = '__figma_cli_unwrap__';
const UNWRAP_ROOT = (v) => `(() => {
if (${v}.name !== ${JSON.stringify(UNWRAP_NAME)} || ${v}.children.length !== 1) return ${v};
const k = ${v}.children[0], par = ${v}.parent;
par.insertChild(par.children.indexOf(${v}), k);
k.x = ${v}.x; k.y = ${v}.y; ${v}.remove();
return k;
})()`;

/**
* Visible fallback colors for shadcn semantic token names (Zinc light theme).
* When a `var:` reference can't be resolved (e.g. the user never loaded any
Expand Down Expand Up @@ -727,6 +738,12 @@ export class FigmaClient {
const layoutWarnings = globalThis.__layoutWarnings || [];
globalThis.__layoutWarnings = [];
const notes = globalThis.__figNotes ? [...globalThis.__figNotes] : [];
for (const r of results) {
const f = await figma.getNodeByIdAsync(r.id);
if (!f) continue;
const k = ${UNWRAP_ROOT('f')};
if (k !== f) { r.id = k.id; r.name = k.name; r.width = k.width; r.height = k.height; }
}
for (const r of results) {
try { const n = await figma.getNodeByIdAsync(r.id); if (n) r.summary = await globalThis.__figHelpers.describe(n, 2, 12); } catch (e) {}
}
Expand Down Expand Up @@ -754,8 +771,13 @@ export class FigmaClient {
* as the root instead of failing with "must start with <Frame>".
*/
normalizeRoot(jsx) {
const m = String(jsx).trim().match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
return m ? `<Frame${m[2]}></Frame>` : jsx;
const t = String(jsx).trim();
const m = t.match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
if (m) return `<Frame${m[2]}></Frame>`;
// A lone <Instance .../> ("place three cards side by side") is built inside
// a temporary hugging frame that is dissolved again, see UNWRAP_ROOT.
if (/^<Instance\b[\s\S]*\/>$/.test(t)) return `<Frame name="${UNWRAP_NAME}" flex="row" hug="both">${t}</Frame>`;
return jsx;
}

/**
Expand Down Expand Up @@ -2172,12 +2194,13 @@ export class FigmaClient {
const __layoutWarnings = globalThis.__layoutWarnings || [];
globalThis.__layoutWarnings = [];
const __notes = globalThis.__figNotes ? [...globalThis.__figNotes] : [];
const __root = ${UNWRAP_ROOT('frame')};
// What was built, so the caller does not need a second call to check it.
let __summary = null;
try { __summary = await globalThis.__figHelpers.describe(frame); } catch (e) {}
try { __summary = await globalThis.__figHelpers.describe(__root); } catch (e) {}
return (__unresolved.length > 0 || __layoutWarnings.length > 0 || __notes.length > 0)
? { id: frame.id, name: frame.name, unresolved: __unresolved, layoutWarnings: __layoutWarnings, notes: __notes, summary: __summary }
: { id: frame.id, name: frame.name, summary: __summary };
? { id: __root.id, name: __root.name, unresolved: __unresolved, layoutWarnings: __layoutWarnings, notes: __notes, summary: __summary }
: { id: __root.id, name: __root.name, summary: __summary };
} catch(e) {
frame.remove();
throw new Error('[Node: ' + __currentNode + '] ' + e.message);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/agent-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ Helpers available inside \`eval\` (they handle the Figma pitfalls for you):
- \`render\` prints the new node id and **the structure it built**: sizes, layout,
padding, gap, bound variables, styles, which component each instance uses.
Check that output; do not re-read the result with \`eval\`. \`--verify\` adds a
screenshot when the look matters.
screenshot when the look matters. \`figma-cli verify <id> --scale 2\` saves a
sharper PNG to read (the default is a small 0.5x preview).
- Instances on the canvas without a wrapper: \`render '<Instance component="Card" />'\`
or several at once with \`render-batch\`.

## Components
- One component: \`render '<Frame ...>' --as-component\`.
Expand Down
25 changes: 21 additions & 4 deletions src/lib/cli-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,19 @@ function getTokenStatus() {
let _daemonHealthCache = { time: 0, value: null };
const DAEMON_HEALTH_TTL_MS = 2000;
function invalidateDaemonHealthCache() { _daemonHealthCache = { time: 0, value: null }; }
let _daemonHealthBody = null;

/**
* True when the running daemon was started from different figma-cli code:
* another version (after npm update) or another install path. render-batch and
* eval run INSIDE the daemon, so a stale one silently serves old behaviour.
* A daemon too old to report its version counts as stale.
*/
function daemonCodeMismatch() {
const h = _daemonHealthBody;
if (!h || h.status === undefined) return false;
return h.version !== pkg.version || (h.root && h.root !== join(__dirname));
}

// Check if daemon is running (returns object with details, or false)
function isDaemonRunning(returnDetails = false, force = false) {
Expand All @@ -254,12 +267,15 @@ function isDaemonRunning(returnDetails = false, force = false) {
try {
const token = getDaemonToken();
const tokenHeader = token ? ` -H "X-Daemon-Token: ${token}"` : '';
const response = execSync(`curl -s -o ${nullDevice} -w "%{http_code}"${tokenHeader} http://localhost:${DAEMON_PORT}/health`, {
// one spawn returns both the body (version, bound file) and the status code
const response = execSync(`curl -s -w "\\n%{http_code}"${tokenHeader} http://localhost:${DAEMON_PORT}/health`, {
encoding: 'utf8',
stdio: 'pipe',
timeout: 1000
});
const statusCode = response.trim();
const lines = response.trim().split('\n');
const statusCode = lines.pop().trim();
try { _daemonHealthBody = JSON.parse(lines.join('\n')); } catch { _daemonHealthBody = null; }

if (returnDetails) {
return {
Expand Down Expand Up @@ -400,8 +416,9 @@ function daemonPinMismatch() {
}

async function ensureDaemonRunning(maxWaitMs = 5000) {
const mismatched = isDaemonRunning() && daemonPinMismatch();
if (isDaemonRunning() && !mismatched) return true;
const running = isDaemonRunning(false, true);
const mismatched = running && (daemonPinMismatch() || daemonCodeMismatch());
if (running && !mismatched) return true;
if (mismatched) {
// Rebind: the daemon holds ONE CDP connection, fixed at startup.
stopDaemon();
Expand Down
9 changes: 9 additions & 0 deletions tests/figma-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,12 @@ describe('strokes and instance tint', () => {
assert.ok(code.includes('__tint'));
});
});

describe('root instances', () => {
const c = new FigmaClient();
it('wraps a lone <Instance> so it can be rendered and dissolves the wrapper afterwards', async () => {
const code = await c.parseJSX('<Instance component="Card" />');
assert.ok(code.includes('__figma_cli_unwrap__'));
assert.ok(code.includes('insertChild'));
});
});
Loading