diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e877476..1bd125c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 ''` and `render-batch` with instances place the instances directly on the page instead of failing with "must start with "
+
+### Changed
+
+- The agent rules and the `eval` output limit point to `figma-cli verify --scale 2` for a sharper screenshot
+
## 2.2.3 (2026-09-26)
### Added
diff --git a/package-lock.json b/package-lock.json
index 6b6d5e39..90309e29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "figma-ds-cli",
- "version": "2.2.3",
+ "version": "2.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "figma-ds-cli",
- "version": "2.2.3",
+ "version": "2.2.4",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
diff --git a/package.json b/package.json
index 257fd3ab..d79ed718 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/commands/export-eval.js b/src/commands/export-eval.js
index e00e904c..66b0fa78 100644
--- a/src/commands/export-eval.js
+++ b/src/commands/export-eval.js
@@ -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 --scale 2 (saves a PNG).`;
}
// $page / $var / $bind / $instance / ... live on globalThis in Figma. Install
diff --git a/src/daemon.js b/src/daemon.js
index 0e384e3a..e26edc60 100644
--- a/src/daemon.js
+++ b/src/daemon.js
@@ -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');
@@ -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;
diff --git a/src/figma-client.js b/src/figma-client.js
index 4a5495b6..ce261cfc 100644
--- a/src/figma-client.js
+++ b/src/figma-client.js
@@ -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 : 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
@@ -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) {}
}
@@ -754,8 +771,13 @@ export class FigmaClient {
* as the root instead of failing with "must start with ".
*/
normalizeRoot(jsx) {
- const m = String(jsx).trim().match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
- return m ? `` : jsx;
+ const t = String(jsx).trim();
+ const m = t.match(/^<(Rectangle|Rect)\b([\s\S]*?)\/>$/);
+ if (m) return ``;
+ // A lone ("place three cards side by side") is built inside
+ // a temporary hugging frame that is dissolved again, see UNWRAP_ROOT.
+ if (/^$/.test(t)) return `${t}`;
+ return jsx;
}
/**
@@ -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);
diff --git a/src/lib/agent-rules.js b/src/lib/agent-rules.js
index ae9b98ee..cb643f1b 100644
--- a/src/lib/agent-rules.js
+++ b/src/lib/agent-rules.js
@@ -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 --scale 2\` saves a
+ sharper PNG to read (the default is a small 0.5x preview).
+- Instances on the canvas without a wrapper: \`render ''\`
+ or several at once with \`render-batch\`.
## Components
- One component: \`render '' --as-component\`.
diff --git a/src/lib/cli-core.js b/src/lib/cli-core.js
index cd6b71b1..c84f3ded 100644
--- a/src/lib/cli-core.js
+++ b/src/lib/cli-core.js
@@ -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) {
@@ -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 {
@@ -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();
diff --git a/tests/figma-client.test.js b/tests/figma-client.test.js
index a810c53d..8b1d0b9b 100644
--- a/tests/figma-client.test.js
+++ b/tests/figma-client.test.js
@@ -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 so it can be rendered and dissolves the wrapper afterwards', async () => {
+ const code = await c.parseJSX('');
+ assert.ok(code.includes('__figma_cli_unwrap__'));
+ assert.ok(code.includes('insertChild'));
+ });
+});