From 9bd3db2fbe7f458e479de9a5d556623ba5bdbebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sil=20Borm=C3=BCller?= Date: Sat, 26 Sep 2026 14:58:16 +0200 Subject: [PATCH] fix: restart a daemon from an older version and render root instances - the daemon reports its version and code path in /health; figma-cli restarts it on a mismatch so render-batch and eval never run stale code after an update - a lone is a valid root for render and render-batch - agent rules and the eval output limit mention verify --scale for sharper screenshots - release 2.2.4 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/commands/export-eval.js | 2 +- src/daemon.js | 8 ++++++++ src/figma-client.js | 33 ++++++++++++++++++++++++++++----- src/lib/agent-rules.js | 5 ++++- src/lib/cli-core.js | 25 +++++++++++++++++++++---- tests/figma-client.test.js | 9 +++++++++ 9 files changed, 85 insertions(+), 14 deletions(-) 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')); + }); +});